Two Sync problems found by checking the real October 25 plan against the board: - Someone on the plan twice (Mike Powell is Leader and Guitar [Acoustic]) could fill two spots, pushing a real player off the board. Which spot the duplicate took depended on the order Planning Center returned people, so it only happened some weeks. Assignment now uses each person once. - Moving a synced person to another spot was undone by the next Sync, which re-placed everyone it had filled. A move is now a hand placement for the current plan and Sync leaves it alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.5 KiB
JavaScript
203 lines
8.5 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;
|
||
function assignMembersToSlots(slots, members) {
|
||
const pool = members.slice();
|
||
return slots.map((slotLabel) => {
|
||
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.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);
|
||
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,
|
||
MIN_SCORE,
|
||
};
|