From 0548cb54fa43ec865b53c37aabde48debb7c7635 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:14:39 -0400 Subject: [PATCH] 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);