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>
255 lines
11 KiB
JavaScript
255 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Slot ↔ Planning Center position matching for the Band Lineup module.
|
||
*
|
||
* A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g.
|
||
* "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO
|
||
* team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign
|
||
* people to slots by loose token overlap — the same logic the backstage
|
||
* Electron app used, lifted here unchanged so the board matches what the booth
|
||
* is used to. Pure functions only: no I/O, so this file is unit-testable.
|
||
*/
|
||
|
||
function normalizeTeamName(value) {
|
||
return String(value || '').trim().toLowerCase();
|
||
}
|
||
|
||
/** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */
|
||
function isAllowedTeamName(value, keywords) {
|
||
const normalized = normalizeTeamName(value);
|
||
if (!normalized) return false;
|
||
return (keywords || []).some((kw) => {
|
||
const k = normalizeTeamName(kw);
|
||
return k && (normalized === k || normalized.includes(k));
|
||
});
|
||
}
|
||
|
||
function normalizeLabel(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
/** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */
|
||
function normalizeToken(token) {
|
||
const normalized = String(token || '').trim().toLowerCase();
|
||
if (!normalized) return '';
|
||
if (/^\d+$/.test(normalized)) return '';
|
||
if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return '';
|
||
if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
|
||
if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
|
||
if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
|
||
if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
|
||
if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
|
||
if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
|
||
if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
|
||
if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
|
||
if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
|
||
if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
|
||
return normalized;
|
||
}
|
||
|
||
function labelTokens(value) {
|
||
const normalized = normalizeLabel(value);
|
||
if (!normalized) return [];
|
||
const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean);
|
||
if (!tokens.length && normalized) return [normalized];
|
||
return [...new Set(tokens)];
|
||
}
|
||
|
||
/**
|
||
* The role part of a slot label to match on: the bracketed content when present
|
||
* ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This
|
||
* drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't
|
||
* be mistaken for a position number.
|
||
*/
|
||
function slotRoleText(label) {
|
||
const brackets = String(label || '').match(/\[([^\]]*)\]/g);
|
||
if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', ');
|
||
return String(label || '');
|
||
}
|
||
|
||
/** The set of standalone numbers in a label ("vox 2" → {"2"}). */
|
||
function numbersIn(value) {
|
||
return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t)));
|
||
}
|
||
|
||
/** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */
|
||
function scoreMatch(slotLabel, positionLabel) {
|
||
const slotNormalized = normalizeLabel(slotLabel);
|
||
const positionNormalized = normalizeLabel(positionLabel);
|
||
if (!slotNormalized || !positionNormalized) return 0;
|
||
// A numbered role only matches the same number: "Vox 3" must not take "Vox 2".
|
||
const slotNums = numbersIn(slotLabel);
|
||
const posNums = numbersIn(positionLabel);
|
||
if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0;
|
||
if (slotNormalized === positionNormalized) return 120;
|
||
if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
|
||
|
||
const slotTokens = labelTokens(slotLabel);
|
||
const positionTokens = labelTokens(positionLabel);
|
||
if (!slotTokens.length || !positionTokens.length) return 0;
|
||
|
||
const positionSet = new Set(positionTokens);
|
||
const slotSet = new Set(slotTokens);
|
||
const overlap = slotTokens.filter((t) => positionSet.has(t));
|
||
if (!overlap.length) return 0;
|
||
|
||
const overlapCount = overlap.length;
|
||
const slotCoverage = overlapCount / slotSet.size;
|
||
const positionCoverage = overlapCount / positionSet.size;
|
||
const sharedPhrase = overlap.join(' ');
|
||
|
||
let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
|
||
if (slotCoverage === 1) score += 18;
|
||
if (positionCoverage === 1) score += 10;
|
||
if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
|
||
return score;
|
||
}
|
||
|
||
/**
|
||
* Assign scheduled members to the ordered slot list. Greedy by best score, each
|
||
* member used once. Returns an array aligned to `slots`, each entry the chosen
|
||
* 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();
|
||
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;
|
||
for (let i = 0; i < pool.length; i += 1) {
|
||
const score = scoreMatch(role, pool[i].label);
|
||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||
}
|
||
if (bestIndex < 0) return null;
|
||
const chosen = pool[bestIndex];
|
||
take(chosen);
|
||
return chosen;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||
* `entries` is [{ ...whatever, name }].
|
||
*/
|
||
function bestLibraryMatch(pcoName, entries) {
|
||
const target = normalizeLabel(pcoName);
|
||
if (!target) return null;
|
||
const T = target.split(' ').filter(Boolean);
|
||
const scoreOf = (name) => {
|
||
const ln = normalizeLabel(name);
|
||
if (!ln) return 0;
|
||
if (ln === target) return 100; // exact full name
|
||
const L = ln.split(' ').filter(Boolean);
|
||
if (!L.length || !T.length) return 0;
|
||
// Align the library name against a leading run of the person's name, token by token.
|
||
// Every library token must equal the person's token in that position — except the LAST
|
||
// library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M"
|
||
// match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni".
|
||
let ok = true; let exact = 0; let usedPrefix = false;
|
||
for (let k = 0; k < L.length; k += 1) {
|
||
const lt = L[k]; const tt = T[k];
|
||
if (!tt) { ok = false; break; }
|
||
if (lt === tt) { exact += 1; continue; }
|
||
if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; }
|
||
ok = false; break;
|
||
}
|
||
if (ok) {
|
||
if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan"
|
||
if (exact === 1) return 90; // first name matches exactly ("alex")
|
||
if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous
|
||
}
|
||
// Otherwise: a library surname that appears anywhere in the person's name.
|
||
const last = L[L.length - 1];
|
||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||
return 0;
|
||
};
|
||
let best = null;
|
||
let bestScore = 0;
|
||
let tie = false;
|
||
for (const e of entries) {
|
||
const s = scoreOf(e.name);
|
||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||
else if (s === bestScore && s > 0) tie = true;
|
||
}
|
||
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||
return best;
|
||
}
|
||
|
||
module.exports = {
|
||
normalizeTeamName,
|
||
isAllowedTeamName,
|
||
bestLibraryMatch,
|
||
normalizeLabel,
|
||
normalizeToken,
|
||
labelTokens,
|
||
slotRoleText,
|
||
numbersIn,
|
||
scoreMatch,
|
||
assignMembersToSlots,
|
||
noteScore,
|
||
mixNumbers,
|
||
MIN_SCORE,
|
||
};
|