Band Lineup 1.12.0: Sync uses Planning Center notes for mics, keeps your moves, and dragging works on touch #51

Merged
christian merged 3 commits from claude/band-lineup-drag into main 2026-10-20 13:17:37 -04:00
4 changed files with 97 additions and 10 deletions
Showing only changes of commit 0548cb54fa - Show all commits
+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);