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);