Band Lineup module, as it runs in North Attleboro

The backstage stage board as a ProdDash tile: everyone scheduled on the
band, vocal and production teams for the upcoming Planning Center plan,
placed into their mic slot with photo, name and mic/pack label. Birthdays
in the service week get confetti. The display half of the standalone Band
Lineup Electron app, ported to a module — imported here exactly as
installed on NA-Ingest (v1.10.1), so the repo is where it lives from now on.
This commit is contained in:
2026-09-22 09:15:22 -04:00
parent 3435a0625e
commit 684f06abed
8 changed files with 1823 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
'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;
return pool.splice(bestIndex, 1)[0];
});
}
/**
* 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,
};