From a692b6a9e284a2e51f8699a245f9d6ee2ee00324 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:06:57 -0400 Subject: [PATCH 1/3] Band Lineup 1.12.0: dragging photos into spots works with a finger and stops dropping out The editor used the browser's built-in drag-and-drop, which never fires for a touchscreen, flickered its drop highlight over a card's inner parts, and lost the drag whenever a board update arrived mid-drag (the editor was rebuilt under the pointer). Dragging is now driven by pointer events, one path for mouse and touch: a mouse drags as soon as it moves, a finger holds still for a moment first so a swipe still scrolls. A floating copy of the photo follows the pointer, the spot under it lights up, the editor auto-scrolls near its edges, and board updates wait until the drop. Tap a photo then tap a spot still works. Co-Authored-By: Claude Opus 5.5 --- modules/band-lineup/client.js | 158 ++++++++++++++++++++++---------- modules/band-lineup/module.json | 2 +- modules/band-lineup/style.css | 17 ++++ 3 files changed, 129 insertions(+), 48 deletions(-) diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js index ab86bc8..1e0b2d8 100644 --- a/modules/band-lineup/client.js +++ b/modules/band-lineup/client.js @@ -1,5 +1,5 @@ // Band Lineup — client. Renders the shared stage board from /state + /stream. -// The Edit button opens an editor: an uploaded-photo library you drag (or click) +// The Edit button opens an editor: an uploaded-photo library you drag (or tap) // into slots, plus rename / clear / upload / delete. Edits go to the server and // come back over SSE, so every Band Lineup screen stays in sync. Birthday week // (Wed→Tue) rains confetti. @@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) { let selectedLibId = ''; // click-to-place fallback for touch const canvases = new Set(); let raf = null; - // Auto-scroll the editor while dragging a photo (native DnD won't do it reliably). - let dragScrolling = false; - let dragY = 0; - let dragBody = null; - let dragRaf = null; - function startDragAutoScroll() { - if (dragRaf) return; - const tick = () => { - if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; } - const r = dragBody.getBoundingClientRect(); - const zone = 90; // generous edge band so you don't have to be pixel-accurate - let dy = 0; - if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3; - else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3; - if (dy) dragBody.scrollTop += dy; - dragRaf = requestAnimationFrame(tick); - }; - dragRaf = requestAnimationFrame(tick); + /* ---------- drag (pointer events: one path for mouse, pen and touch) ---------- + * Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements + * flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This + * drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for + * a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo + * follows the pointer, the spot under it lights up, and the editor auto-scrolls near its + * edges. Board updates that arrive during a drag wait until it ends. */ + const DRAG_MOVE_PX = 6; + const TOUCH_HOLD_MS = 220; + let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf } + let pending = null; // a pointerdown that may become a drag + let renderHeld = false; // a state update arrived mid-drag; render when it ends + + function slotUnder(x, y) { + const hit = document.elementFromPoint(x, y); + const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null; + return cell && root.contains(cell) ? cell : null; } - function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } } + function beginDrag(p) { + const ghost = el('div', 'bl-drag-ghost'); + ghost.dataset.module = 'band-lineup'; // lives on (above every tile), so it carries its own scope + const src = p.source.querySelector('img'); + if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); } + else ghost.textContent = p.label || ''; + document.body.appendChild(ghost); + drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source }; + p.source.classList.add('bl-dragging'); + root.classList.add('bl-is-dragging'); + 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; + 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); } + } + drag.raf = requestAnimationFrame(tick); + }; + drag.raf = requestAnimationFrame(tick); + } + function moveDrag(x, y) { + drag.x = x; drag.y = y; + drag.ghost.style.transform = `translate(${x}px, ${y}px)`; + const cell = slotUnder(x, y); + if (cell !== drag.over) { + if (drag.over) drag.over.classList.remove('bl-drop-over'); + drag.over = cell; + if (cell) cell.classList.add('bl-drop-over'); + } + } + function endDrag(drop) { + const d = drag; drag = null; + if (!d) return; + if (d.raf) cancelAnimationFrame(d.raf); + d.ghost.remove(); + d.source.classList.remove('bl-dragging'); + root.classList.remove('bl-is-dragging'); + if (d.over) d.over.classList.remove('bl-drop-over'); + if (drop && d.over) { + const to = d.over.dataset.index; + if (d.kind === 'lib') assign(to, d.id); + else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' }); + } + if (renderHeld) { renderHeld = false; render(); } + } + function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; } + // Make `node` draggable. `kind`/`id` say what is being dragged. + function makeDraggable(node, kind, id, body, label) { + node.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || drag) return; + if (e.target.closest('button')) return; // name / clear / delete buttons keep working + cancelPending(); + pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null }; + if (pending.touch) { + const p = pending; + p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS); + } + }); + } + // Document-level move/up so the drag survives leaving the card it started on. + function onPointerMove(e) { + if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; } + if (!pending || e.pointerId !== pending.pointerId) return; + const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy); + if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll + if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); } + } + function onPointerUp(e) { + if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); } + cancelPending(); + } + let suppressClick = false; // the click that follows a drop must not also select / place + // While a finger drag is live, stop the page from scrolling under it. + function onTouchMove(e) { if (drag) e.preventDefault(); } + document.addEventListener('pointermove', onPointerMove, { passive: false }); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', onPointerUp); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + function stopDragAutoScroll() { cancelPending(); endDrag(false); } /* ---------- confetti (always animates) ---------- */ function makeParticle(w, h, intensity, seedY) { @@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) { function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } } function render() { + if (drag) { renderHeld = true; return; } stopConfetti(); stopParallax(); root.innerHTML = ''; @@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) { // board on top and the picture picker (library) below it. const wrap = el('div', 'bl-editor bl-editor-nobar'); const body = el('div', 'bl-editor-body'); - // Feed the drag auto-scroller the pointer position anywhere over the editor. - body.addEventListener('dragover', (e) => { dragY = e.clientY; }); // Slots (drop targets) const boardEl = el('div', 'bl-edit-board'); @@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) { photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+')); cell.appendChild(photo); // An occupied card is a drag SOURCE too — move/swap it to another slot. - if (slot.person) { - cell.draggable = true; - cell.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move'; - dragBody = body; dragScrolling = true; startDragAutoScroll(); - }); - cell.addEventListener('dragend', stopDragAutoScroll); - } + if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name); const nameRow = el('div', 'bl-edit-namerow'); const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…'); nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : '')); @@ -191,16 +264,7 @@ export default function create({ root, moduleApi }) { if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); } cell.appendChild(nameRow); cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`)); - // drop - cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); }); - cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over')); - cell.addEventListener('drop', (e) => { - e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over'); - const data = e.dataTransfer.getData('text/plain') || ''; - if (data.startsWith('lib:')) assign(slot.index, data.slice(4)); - else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); } - }); - cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); + 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); @@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) { 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.')); for (const person of list) { - const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id; + const item = el('div', 'bl-lib-item'); item.dataset.id = person.id; if (person.id === selectedLibId) item.classList.add('bl-lib-selected'); const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph); const nm = el('button', 'bl-lib-name', person.name || 'Name…'); @@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) { const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo'; del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); }); item.appendChild(del); - item.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy'; - dragBody = body; dragScrolling = true; startDragAutoScroll(); - }); - item.addEventListener('dragend', stopDragAutoScroll); - item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); + makeDraggable(item, 'lib', person.id, body, person.name); + item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); lib.appendChild(item); } body.appendChild(lib); @@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) { stopConfetti(); stopParallax(); stopDragAutoScroll(); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', onPointerUp); + document.removeEventListener('touchmove', onTouchMove); root.innerHTML = ''; }, }; diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json index df2cc60..56e431f 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.11.1", + "version": "1.12.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/style.css b/modules/band-lineup/style.css index 68fe5bf..55214f1 100644 --- a/modules/band-lineup/style.css +++ b/modules/band-lineup/style.css @@ -230,3 +230,20 @@ background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1; } [data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; } + +/* Drag: the floating photo that follows the pointer (appended to , so it scopes itself). */ +[data-module="band-lineup"].bl-drag-ghost { + position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none; + width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden; + border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px; + display: flex; align-items: center; justify-content: center; text-align: center; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92; +} +[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; } +[data-module="band-lineup"] .bl-lib-item, +[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } +[data-module="band-lineup"] .bl-edit-slot img, +[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; } +[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; } From c1152b4ab3ac101cfd7b18eeb7c7518c973bbd0b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:11:56 -0400 Subject: [PATCH 2/3] Band Lineup 1.12.0: Sync keeps your moves and puts each person in one spot Two Sync problems found by checking the real October 25 plan against the board: - Someone on the plan twice (Mike Powell is Leader and Guitar [Acoustic]) could fill two spots, pushing a real player off the board. Which spot the duplicate took depended on the order Planning Center returned people, so it only happened some weeks. Assignment now uses each person once. - Moving a synced person to another spot was undone by the next Sync, which re-placed everyone it had filled. A move is now a hand placement for the current plan and Sync leaves it alone. Co-Authored-By: Claude Opus 5.5 --- modules/band-lineup/matching.js | 6 +++++- modules/band-lineup/matching.test.js | 9 +++++++++ modules/band-lineup/server.js | 8 ++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index cfea022..a12701d 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -128,7 +128,11 @@ function assignMembersToSlots(slots, members) { if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex < 0) return null; - return pool.splice(bestIndex, 1)[0]; + const chosen = pool.splice(bestIndex, 1)[0]; + // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. + const key = chosen.personId || ''; + if (key) for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i].personId === key) pool.splice(i, 1); + return chosen; }); } diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 516574c..90f30c8 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -65,3 +65,12 @@ console.log('assignment:', JSON.stringify(out)); assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null'); console.log('bestLibraryMatch: all assertions passed ✓'); } + +// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only --- +{ + const P = (label, name, personId) => ({ label, name, personId }); + const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7']; + const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null)); + assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot'); + console.log('one spot per person: all assertions passed ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index 92bcf63..abc1057 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -530,8 +530,8 @@ module.exports = { const openIdx = []; const manualNames = new Set(); for (let i = 0; i < eff.length; i += 1) { const e = board[String(i)]; - if (e && (e.personId || e.name) && e.plan === planId) { - const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name; + if (e && (e.personId || e.name || e.pco) && e.plan === planId) { + const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name; if (nm) manualNames.add(normName(nm)); } else openIdx.push(i); } @@ -675,6 +675,10 @@ module.exports = { const from = String(p.get('from') || ''); const to = String(p.get('to') || ''); if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' }); const a = current.board[from]; const b = current.board[to]; + // Moving a spot is a hand placement for this plan: Sync must not move it back. + const plan = current.planId(); + if (a) a.plan = plan; + if (b) b.plan = plan; if (a === undefined) delete current.board[to]; else current.board[to] = a; if (b === undefined) delete current.board[from]; else current.board[from] = b; current.saveBoard(); current.rebuildLocal(); From 0548cb54fa43ec865b53c37aabde48debb7c7635 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:14:39 -0400 Subject: [PATCH 3/3] Band Lineup 1.12.0: Sync puts the leader and vocalists on the mic their Planning Center note names The booth writes each vocalist's and the leader's mic into their Planning Center note every week ("Mix 8, VOX 5", "Mix 1, Vox 1"), because those change week to week and the position name ("Vocalist") carries no number. Sync ignored the notes and dropped the singers onto Vox 2-5 in whatever order Planning Center listed them. Sync now reads each team member's note and places anyone whose note names a spot there first: the mix number must agree with the slot's MIX, and the role ("VOX 5", "Drums") is scored like a position. "NA" or a blank note says nothing. Everyone else is placed by position as before. Tests use the real Oct 25 and Nov 1 notes. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++--- modules/band-lineup/matching.js | 62 +++++++++++++++++++++++++--- modules/band-lineup/matching.test.js | 29 +++++++++++++ modules/band-lineup/server.js | 1 + 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 614b85c..53cb9f7 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,16 @@ 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, drag between slots - to swap, rename or clear. Planning Center is optional auto-fill on top, not - the source of truth: **Sync** reads the upcoming plan's team members and - drops each into the slot matching their position, filling only slots nobody - placed by hand, so a guest pinned on Thursday survives Sunday's sync. + title bar opens that library — drag a photo onto a slot (mouse, or press and + hold on a touchscreen), drag between slots to swap, rename or clear. + 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 + 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 raining confetti, and a Choir button adds the extra spot for the weeks there is one. Admin config: Planning Center credentials, service type and plan, the diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index a12701d..e5e1180 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) { * member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot. */ const MIN_SCORE = 60; + +/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */ +function mixNumbers(value) { + const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m; + while ((m = re.exec(String(value || '')))) out.add(m[1]); + return out; +} + +/** + * How well a person's Planning Center note names a spot. The booth writes the mic/role into each + * vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those + * change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a + * different mix than the spot is never a match; the role part ("VOX 5") is scored like a position. + * 0 means the note says nothing usable about this spot ("NA", blank, or no overlap). + */ +function noteScore(slotLabel, note) { + const text = String(note || '').trim(); + if (!text || /^n\/?a$/i.test(text)) return 0; + const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text); + let mixBonus = 0; + if (noteMix.size && slotMix.size) { + if (![...noteMix].some((n) => slotMix.has(n))) return 0; + mixBonus = 100; + } + const role = text.replace(/\bmix\s*\d+\b/gi, ' '); + const roleScore = scoreMatch(slotRoleText(slotLabel), role); + if (!roleScore && !mixBonus) return 0; + if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) { + // The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither. + const roleTokens = labelTokens(role); + if (roleTokens.length) return 0; + } + return mixBonus + roleScore; +} + function assignMembersToSlots(slots, members) { const pool = members.slice(); - return slots.map((slotLabel) => { + const result = slots.map(() => null); + const take = (chosen) => { + // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. + const key = chosen.personId || ''; + for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1); + }; + // Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first. + const pairs = []; + slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); })); + pairs.sort((a, b) => b.sc - a.sc); + for (const { s, m } of pairs) { + if (result[s] || !pool.includes(m)) continue; + result[s] = m; take(m); + } + // Pass 2 — everyone else by Planning Center position, into the spots still open. + return result.map((placed, s) => { + if (placed) return placed; + const slotLabel = slots[s]; const role = slotRoleText(slotLabel); let bestIndex = -1; let bestScore = MIN_SCORE - 1; @@ -128,10 +180,8 @@ function assignMembersToSlots(slots, members) { if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex < 0) return null; - const chosen = pool.splice(bestIndex, 1)[0]; - // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. - const key = chosen.personId || ''; - if (key) for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i].personId === key) pool.splice(i, 1); + const chosen = pool[bestIndex]; + take(chosen); return chosen; }); } @@ -198,5 +248,7 @@ module.exports = { numbersIn, scoreMatch, assignMembersToSlots, + noteScore, + mixNumbers, MIN_SCORE, }; diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 90f30c8..d68e859 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -74,3 +74,32 @@ console.log('assignment:', JSON.stringify(out)); assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot'); console.log('one spot per person: all assertions passed ✓'); } + +// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) --- +{ + const { noteScore } = require('./matching'); + const NA_SLOTS = [ + '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5', + '[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]', + ]; + const P = (label, name, personId, note) => ({ label, name, personId, note }); + // Listed in the order Planning Center returned them — vocalists alphabetical, not by mic. + const oct25 = [ + P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'), + P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'), + P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'), + P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'), + ]; + const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null)); + assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes'); + // Nov 1: leader note in different case, a drum note with no mix, an "NA" note. + const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')]; + assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums'); + assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]'); + // A note never pulls someone onto a different mix. + assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2'); + assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8'); + assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing'); + 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 ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index abc1057..5c3b641 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -512,6 +512,7 @@ module.exports = { personId, name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown', label: String(m.attributes?.team_position_name || '').trim(), + note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(), }); if (personId) seen.add(personId);