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
|
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
|
"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
|
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
|
title bar opens that library — drag a photo onto a slot (mouse, or press and
|
||||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
Planning Center is optional auto-fill on top, not the source of truth:
|
||||||
drops each into the slot matching their position, filling only slots nobody
|
**Sync** reads the upcoming plan's team members and places each one. A
|
||||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
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
|
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
|
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
|
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.
|
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||||
*/
|
*/
|
||||||
const MIN_SCORE = 60;
|
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) {
|
function assignMembersToSlots(slots, members) {
|
||||||
const pool = members.slice();
|
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);
|
const role = slotRoleText(slotLabel);
|
||||||
let bestIndex = -1;
|
let bestIndex = -1;
|
||||||
let bestScore = MIN_SCORE - 1;
|
let bestScore = MIN_SCORE - 1;
|
||||||
@@ -128,10 +180,8 @@ function assignMembersToSlots(slots, members) {
|
|||||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||||
}
|
}
|
||||||
if (bestIndex < 0) return null;
|
if (bestIndex < 0) return null;
|
||||||
const chosen = pool.splice(bestIndex, 1)[0];
|
const chosen = pool[bestIndex];
|
||||||
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
take(chosen);
|
||||||
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);
|
|
||||||
return chosen;
|
return chosen;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -198,5 +248,7 @@ module.exports = {
|
|||||||
numbersIn,
|
numbersIn,
|
||||||
scoreMatch,
|
scoreMatch,
|
||||||
assignMembersToSlots,
|
assignMembersToSlots,
|
||||||
|
noteScore,
|
||||||
|
mixNumbers,
|
||||||
MIN_SCORE,
|
MIN_SCORE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,3 +74,32 @@ console.log('assignment:', JSON.stringify(out));
|
|||||||
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||||
console.log('one spot per person: all assertions passed ✓');
|
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,
|
personId,
|
||||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||||
label: String(m.attributes?.team_position_name || '').trim(),
|
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(),
|
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||||
});
|
});
|
||||||
if (personId) seen.add(personId);
|
if (personId) seen.add(personId);
|
||||||
|
|||||||
Reference in New Issue
Block a user