Band Lineup 1.12.0: Sync uses Planning Center notes for mics, keeps your moves, and dragging works on touch #51
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 ✓');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user