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 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 13:14:39 -04:00
co-authored by Claude Opus 5.5
parent c1152b4ab3
commit 0548cb54fa
4 changed files with 97 additions and 10 deletions
+10 -5
View File
@@ -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
+57 -5
View File
@@ -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,
};
+29
View File
@@ -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 ✓');
}
+1
View File
@@ -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);