diff --git a/README.md b/README.md
index 53cb9f7..eae34dd 100644
--- a/README.md
+++ b/README.md
@@ -521,14 +521,21 @@ below. Nothing is installed without someone clicking.
A photo library fills it: point the module at a Google Drive folder shared
"anyone with the link" (file name → person's name) or at a folder on the
ProdDash machine, and photos added or removed there follow. **Edit** in the
- title bar opens that library — drag a photo onto a slot (mouse, or press and
+ title bar opens that library — the spots pinned on top, the photos (with a
+ name search) scrolling below — drag a photo onto a slot (mouse, or press and
hold on a touchscreen), drag between slots to swap, rename or clear.
+ Dropping a photo onto the person already in a spot makes it *their* photo
+ (a nickname like "Rose" for Rosaliz counts): they keep their Planning Center
+ name and spot, and every later Sync uses that photo for them.
Planning Center is optional auto-fill on top, not the source of truth:
**Sync** reads the upcoming plan's team members and places each one. A
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
that slot — this is how the leader and vocalists, whose position is just
"Vocalist", land on the right mic — and everyone else goes to the slot
- matching their position, one slot per person. Anything placed or moved by
+ matching their position, one slot per person. Photos come from the library
+ whenever it has one of them — their remembered pick, else the best name
+ match, even when several photos fit — and Planning Center's headshot only
+ when it has none. Anything placed or moved by
hand for that plan stays put through later syncs; last week's people are
replaced.
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js
index 1e0b2d8..3e93c29 100644
--- a/modules/band-lineup/client.js
+++ b/modules/band-lineup/client.js
@@ -45,9 +45,10 @@ export default function create({ root, moduleApi }) {
moveDrag(p.x, p.y);
const tick = () => {
if (!drag) return;
- const b = drag.body;
- if (b && b.isConnected) {
- const r = b.getBoundingClientRect(); const zone = 70; let dy = 0;
+ // Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
+ const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
+ if (b && b.scrollHeight > b.clientHeight) {
+ const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
@@ -163,9 +164,9 @@ export default function create({ root, moduleApi }) {
if (drag) { renderHeld = true; return; }
stopConfetti();
stopParallax();
- root.innerHTML = '';
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
- if (editing) { renderEditor(); return; }
+ if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
+ root.innerHTML = '';
const board = el('div', 'bl-board');
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
@@ -240,11 +241,16 @@ export default function create({ root, moduleApi }) {
}
/* ---------- editor ---------- */
+ let libFilter = ''; // the photo search box, kept across re-renders
function renderEditor() {
- // No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
- // board on top and the picture picker (library) below it.
+ // No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
+ // that scroll on their own: the spots pinned on top, the photos below — so a photo found far
+ // down the list never has to be carried back up to its spot. Both panes keep their scroll
+ // position (and the search box its focus) when a board update redraws the editor.
+ const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
+ const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
const wrap = el('div', 'bl-editor bl-editor-nobar');
- const body = el('div', 'bl-editor-body');
+ const body = el('div', 'bl-editor-body bl-editor-split');
// Slots (drop targets)
const boardEl = el('div', 'bl-edit-board');
@@ -267,9 +273,15 @@ export default function create({ root, moduleApi }) {
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
boardEl.appendChild(cell);
}
- body.appendChild(boardEl);
+ const boardPane = el('div', 'bl-pane bl-board-pane');
+ boardPane.appendChild(boardEl);
+ body.appendChild(boardPane);
- // Library
+ // Library, with a name search
+ const libPane = el('div', 'bl-pane bl-lib-pane');
+ const search = el('input', 'bl-lib-search');
+ search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
+ libPane.appendChild(search);
const lib = el('div', 'bl-library');
const list = state?.library || [];
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
@@ -287,11 +299,23 @@ export default function create({ root, moduleApi }) {
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
lib.appendChild(item);
}
- body.appendChild(lib);
+ const applyFilter = () => {
+ const q = libFilter.trim().toLowerCase();
+ for (const item of lib.querySelectorAll('.bl-lib-item')) {
+ const nm = item.querySelector('.bl-lib-name');
+ item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
+ }
+ };
+ search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
+ applyFilter();
+ libPane.appendChild(lib);
+ body.appendChild(libPane);
wrap.appendChild(body);
root.innerHTML = '';
root.appendChild(wrap);
+ boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
+ if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
}
function assign(slotIndex, personId) {
@@ -320,7 +344,7 @@ export default function create({ root, moduleApi }) {
title: 'Sync',
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
});
- const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
+ const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
// Columns −/+ (touch-friendly; replaces the gear number field).
const MINUS_ICON = '';
const PLUS_ICON = '';
diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js
index e5e1180..550c13f 100644
--- a/modules/band-lineup/matching.js
+++ b/modules/band-lineup/matching.js
@@ -189,8 +189,9 @@ function assignMembersToSlots(slots, members) {
/**
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
- * preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
- * match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
+ * preferring the most specific. Returns the entry, or null when no library photo is a confident
+ * match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
+ * not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
* `entries` is [{ ...whatever, name }].
*/
function bestLibraryMatch(pcoName, entries) {
@@ -227,17 +228,29 @@ function bestLibraryMatch(pcoName, entries) {
};
let best = null;
let bestScore = 0;
- let tie = false;
for (const e of entries) {
const s = scoreOf(e.name);
- if (s > bestScore) { bestScore = s; best = e; tie = false; }
- else if (s === bestScore && s > 0) tie = true;
+ if (s > bestScore) { bestScore = s; best = e; }
}
- if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
+ if (bestScore < 60) return null; // no confident match → PCO photo
return best;
}
+/**
+ * Could this library photo be of this Planning Center person? Used when someone drops a photo onto
+ * a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
+ * A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
+ * first three letters (a nickname: "Rose" → "Rosaliz").
+ */
+function samePerson(pcoName, libName) {
+ if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
+ const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = normalizeLabel(libName).split(' ')[0] || '';
+ if (a && a === b) return true;
+ return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
+}
+
module.exports = {
+ samePerson,
normalizeTeamName,
isAllowedTeamName,
bestLibraryMatch,
diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js
index d68e859..933ae79 100644
--- a/modules/band-lineup/matching.test.js
+++ b/modules/band-lineup/matching.test.js
@@ -61,8 +61,9 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
// exact full name beats a first-name photo
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
- // tie → no match (two identical-scoring photos)
- assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
+ // two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
+ assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
+ assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
console.log('bestLibraryMatch: all assertions passed ✓');
}
@@ -103,3 +104,15 @@ console.log('assignment:', JSON.stringify(out));
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
console.log('notes: all assertions passed ✓');
}
+
+// --- samePerson: is a dropped photo of the person already in the spot? ---
+{
+ const { samePerson } = require('./matching');
+ assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
+ assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
+ assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
+ assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
+ assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
+ assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
+ console.log('samePerson: all assertions passed ✓');
+}
diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json
index 56e431f..26f243f 100644
--- a/modules/band-lineup/module.json
+++ b/modules/band-lineup/module.json
@@ -1,7 +1,7 @@
{
"id": "band-lineup",
"name": "Band Lineup",
- "version": "1.12.0",
+ "version": "1.13.0",
"proddash": ">=1.4.0",
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
"client": "client.js",
diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js
index 5c3b641..e3aed0f 100644
--- a/modules/band-lineup/server.js
+++ b/modules/band-lineup/server.js
@@ -32,7 +32,7 @@ const path = require('path');
const os = require('os');
const crypto = require('crypto');
-const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
+const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson } = require('./matching');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
@@ -219,6 +219,20 @@ module.exports = {
const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
+ // The photo the booth picked for a Planning Center person (PCO person id → library id), learned
+ // when a library photo is dropped onto that person. Sync uses it before any name matching.
+ if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
+ function rememberPhoto(pcoPersonId, libId) {
+ if (!pcoPersonId || !libId || settings.photoFor[pcoPersonId] === libId) return;
+ settings.photoFor[pcoPersonId] = libId; saveSettings();
+ }
+ // Library photo for a scheduled person: their remembered pick, else the best name match.
+ function libraryPhotoFor(personId, name) {
+ const picked = settings.photoFor[personId];
+ if (picked && library[picked] && library[picked].file) return { id: picked, ...library[picked] };
+ const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })));
+ return m && m.file ? m : null;
+ }
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR';
@@ -449,11 +463,13 @@ module.exports = {
if (!e) return null;
let name = '', file = '', birthday = '', manual = true, personId = '';
if (e.pco) {
- // The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
+ // A synced person. Their library photo (if any) is read live, so replacing that photo
+ // updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
manual = false; personId = e.pco.personId || '';
name = String(e.pco.name || '').trim();
- file = e.pco.file || '';
- birthday = e.pco.birthday || '';
+ const lib = e.pco.libId ? library[e.pco.libId] : null;
+ file = (lib && lib.file) || e.pco.file || '';
+ birthday = e.pco.birthday || (lib && lib.birthday) || '';
} else if (e.personId) {
const lib = library[e.personId];
if (!lib) return null; // its library photo was deleted → slot is empty
@@ -521,7 +537,18 @@ module.exports = {
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
for (const m of members) m.birthday = bdays.get(m.personId) || '';
- const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
+ // A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
+ // ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
+ // Planning Center name, kept in that spot.
+ for (const [i, e] of Object.entries(board)) {
+ if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
+ const libName = library[e.personId].name;
+ const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
+ if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
+ const m = who[0];
+ rememberPhoto(m.personId, e.personId);
+ board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId }, plan: e.plan };
+ }
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
@@ -542,9 +569,9 @@ module.exports = {
for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k];
if (m) {
- const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
- const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
- board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
+ const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot
+ const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
+ board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId } };
} else if (board[String(i)]) {
delete board[String(i)]; // synced or stale, and no longer on the plan
}
@@ -586,6 +613,7 @@ module.exports = {
streams, getLatest: () => latest, pco, listPlans, photoDir,
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
planId: () => (latest.plan && latest.plan.id) || '',
+ rememberPhoto,
photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn,
};
@@ -694,7 +722,13 @@ module.exports = {
const personId = String(q.get('personId') || '').trim();
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
- if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
+ const here = current.board[slot];
+ if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
+ // A photo of the person already in this spot: it's their photo now — this week and every
+ // Sync after. They keep their Planning Center name and their spot.
+ current.rememberPhoto(here.pco.personId, personId);
+ current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
+ } else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
else delete current.board[slot];
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
diff --git a/modules/band-lineup/style.css b/modules/band-lineup/style.css
index 55214f1..5f6c6b8 100644
--- a/modules/band-lineup/style.css
+++ b/modules/band-lineup/style.css
@@ -247,3 +247,17 @@
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
+
+/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
+[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
+[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
+[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
+[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
+[data-module="band-lineup"] .bl-lib-search {
+ display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
+ border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
+}
+[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
+[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
+[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
+[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }