2026-09-22 09:15:22 -04:00
|
|
|
|
'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;
|
2026-10-20 13:14:39 -04:00
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 09:15:22 -04:00
|
|
|
|
function assignMembersToSlots(slots, members) {
|
|
|
|
|
|
const pool = members.slice();
|
2026-10-20 13:14:39 -04:00
|
|
|
|
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];
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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;
|
2026-10-20 13:14:39 -04:00
|
|
|
|
const chosen = pool[bestIndex];
|
|
|
|
|
|
take(chosen);
|
2026-10-20 13:11:56 -04:00
|
|
|
|
return chosen;
|
2026-09-22 09:15:22 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-10-20 13:14:39 -04:00
|
|
|
|
noteScore,
|
|
|
|
|
|
mixNumbers,
|
2026-09-22 09:15:22 -04:00
|
|
|
|
MIN_SCORE,
|
|
|
|
|
|
};
|