From e59629694ecff1a96ab511cadcae609679fd0e75 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:28:49 -0400 Subject: [PATCH 1/2] Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos. Sync fell back to the Planning Center headshot whenever two library photos matched a person equally ("Mike" and "mike"), and had no way to know "Rose" is Rosaliz — so the booth dragged those in by hand every week. Now: - Several equally good library photos is not a reason to use PCO's; the first of them is used. PCO's headshot is only for someone with no photo. - Dropping a library photo onto the synced person already in that spot (same name, same first name, or a nickname sharing its first three letters) makes it their photo: they keep their Planning Center name and spot, and it is remembered (settings.json photoFor) for every later Sync. Photos already dragged in by hand for this plan are learned the same way on the next Sync. - A synced person's library photo is read live, so replacing that photo updates the board without a re-sync. Dragging. The editor was one scrolling column, so a photo found far down the list had to be carried back up to its spot, and every drop redrew the editor back to the top. The spots are now pinned in their own pane above the photos, each pane auto-scrolls when a drag nears its edge, both keep their scroll position through redraws, and a search box filters the photos by name. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++-- modules/band-lineup/client.js | 48 ++++++++++++++++++------- modules/band-lineup/matching.js | 25 +++++++++---- modules/band-lineup/matching.test.js | 17 +++++++-- modules/band-lineup/module.json | 2 +- modules/band-lineup/server.js | 52 +++++++++++++++++++++++----- modules/band-lineup/style.css | 14 ++++++++ 7 files changed, 137 insertions(+), 32 deletions(-) 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; } From 1fd7dcdf1e27c600f6acb58447856554ea7dcd7c Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:40:00 -0400 Subject: [PATCH 2/2] Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week DJ plays drums one week and sings the next; one photo can't fit both. A person can now have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for everything else. Sync reads the instrument family of the spot each person lands in (its bracketed role, else their PCO position) and picks: 1. the photo picked by hand for them on that instrument; 2. a photo named for that instrument; 3. their everyday pick, else their best-named photo. A photo of them on another instrument still beats PCO's headshot. Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad, Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo dropped onto someone is remembered per person per instrument ("|drums"), so a drum pick never overrides the vocal photo. A spot naming several roles files under its most specific: Leader, then Vocals, Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces left by "Name - Instrument". Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++--- modules/band-lineup/matching.js | 63 +++++++++++++++++++++++++--- modules/band-lineup/matching.test.js | 25 +++++++++++ modules/band-lineup/server.js | 46 ++++++++++++-------- 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index eae34dd..56bcd19 100644 --- a/README.md +++ b/README.md @@ -525,17 +525,22 @@ below. Nothing is installed without someone clicking. 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. + for that instrument (a nickname like "Rose" for Rosaliz counts): they keep + their Planning Center name and spot, and every later Sync uses it whenever + they are on that instrument. 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. 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 + whenever it has one of them, and Planning Center's headshot only when it + has none. Someone who plays more than one thing can have a photo per + instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`; + Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and + a plain `DJ.jpg` for everything else: Sync uses the one for the spot they + are in that week. A photo dropped onto someone is remembered for that + instrument only. 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/matching.js b/modules/band-lineup/matching.js index 550c13f..b2d7261 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -186,15 +186,60 @@ function assignMembersToSlots(slots, members) { }); } +/* ── photos by instrument ────────────────────────────────────────────── + * A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg", + * "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo + * whose instrument matches the spot the person is in that week. Instruments group into families so + * the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass, + * Guitar/Electric/Acoustic/Rhythm, Leader. */ +const ROLE_FAMILY = { + vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys', + drums: 'drums', percussion: 'drums', bass: 'bass', + guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader', +}; +/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */ +function roleFamilies(text) { + const out = new Set(); + for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); } + return out; +} +/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */ +function spotFamilies(slotLabel, positionLabel) { + const fromSlot = roleFamilies(slotRoleText(slotLabel)); + return fromSlot.size ? fromSlot : roleFamilies(positionLabel || ''); +} +/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}. + * The first word is always the person; instrument words after it are read off, other words + * ("action", "2") are just part of the photo's description. */ +function photoParts(libName) { + const words = normalizeLabel(libName).split(' ').filter(Boolean); + const roles = new Set(); const person = []; + words.forEach((w, i) => { + const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null; + if (f) roles.add(f); else if (!roles.size) person.push(w); + }); + return { person: person.join(' '), roles }; +} +/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick + * never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under + * its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */ +const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar']; +function photoKey(personId, families) { + const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || ''; + return f ? `${personId}|${f}` : String(personId || ''); +} + /** * 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 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 }]. + * `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the + * person is on this week: a photo for that instrument beats their everyday photo, which beats a + * photo of them on something else — but any photo of theirs still beats PCO's headshot. */ -function bestLibraryMatch(pcoName, entries) { +function bestLibraryMatch(pcoName, entries, want) { const target = normalizeLabel(pcoName); if (!target) return null; const T = target.split(' ').filter(Boolean); @@ -226,13 +271,17 @@ function bestLibraryMatch(pcoName, entries) { if (last && last.length >= 3 && T.includes(last)) return 78; return 0; }; + const wanted = want && want.size ? want : null; let best = null; let bestScore = 0; for (const e of entries) { - const s = scoreOf(e.name); + const parts = photoParts(e.name); + let s = scoreOf(parts.roles.size ? parts.person : e.name); + if (s < 60) continue; // not them + if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20; if (s > bestScore) { bestScore = s; best = e; } } - if (bestScore < 60) return null; // no confident match → PCO photo + if (!best) return null; // no confident match → PCO photo return best; } @@ -244,13 +293,17 @@ function bestLibraryMatch(pcoName, entries) { */ function samePerson(pcoName, libName) { if (bestLibraryMatch(pcoName, [{ name: libName }])) return true; - const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = normalizeLabel(libName).split(' ')[0] || ''; + const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.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, + roleFamilies, + spotFamilies, + photoParts, + photoKey, normalizeTeamName, isAllowedTeamName, bestLibraryMatch, diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 933ae79..1d65579 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -116,3 +116,28 @@ console.log('assignment:', JSON.stringify(out)); assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix'); console.log('samePerson: all assertions passed ✓'); } + +// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else --- +{ + const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching'); + // File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums". + const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }]; + const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; }; + assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo'); + assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo'); + assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo'); + assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo'); + assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family'); + assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO'); + assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument'); + assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed'); + assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored'); + assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument'); + assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person'); + assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position'); + assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar'); + assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums'); + assert.strictEqual(photoKey('dj', new Set()), 'dj'); + assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader'); + console.log('photos by instrument: all assertions passed ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index e3aed0f..60a6f63 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, samePerson } = require('./matching'); +const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = 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,19 +219,26 @@ 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. + // The photo the booth picked for a Planning Center person on an instrument ("|drums" + // → library id; a bare person id is their everyday pick), 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(); + function rememberPhoto(key, libId) { + if (!key || !libId || settings.photoFor[key] === libId) return; + settings.photoFor[key] = 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; + // Library photo for a scheduled person in a spot whose instruments are `fams`: + // 1. the photo picked for them on this instrument; + // 2. a photo named for this instrument ("DJ - Drums"); + // 3. their everyday pick, else their best-named photo (an other-instrument photo of them + // still beats PCO's headshot). + function libraryPhotoFor(personId, name, fams) { + const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null); + const forRole = live(settings.photoFor[photoKey(personId, fams)]); + if (forRole) return forRole; + const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams); + if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m; + return live(settings.photoFor[personId]) || (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}`); } } @@ -327,7 +334,7 @@ module.exports = { * left alone, so a folder and hand-uploads can coexist. Returns true if anything changed. */ const IMG_RE = /\.(jpe?g|png|webp|gif)$/i; - const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim(); + const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums" function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; } function scanFolder() { if (!photoFolder) return false; @@ -546,8 +553,8 @@ module.exports = { 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 }; + rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId); + board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan }; } // Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that @@ -569,9 +576,10 @@ module.exports = { for (let k = 0; k < openIdx.length; k += 1) { const i = openIdx[k]; const m = assigned[k]; if (m) { - const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot + // The booth's own photos win over the PCO headshot — the one for the instrument they're on. + const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label)); 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 } }; + board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } }; } else if (board[String(i)]) { delete board[String(i)]; // synced or stale, and no longer on the plan } @@ -614,6 +622,7 @@ module.exports = { library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots, planId: () => (latest.plan && latest.plan.id) || '', rememberPhoto, + slotLabel: (i) => effectiveSlots()[Number(i)] || '', photoUrlFor, libraryList, setChoir, choirOn: () => choirOn, }; @@ -726,7 +735,8 @@ module.exports = { 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); + // Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone. + current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), 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];