Files
ProdDash/modules/band-lineup/matching.js
T
christianandClaude Opus 5.5 e59629694e Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging
Photos. Sync fell back to the Planning Center headshot whenever two
library photos matched a person equally ("Mike" and "mike"), and had no
way to know "Rose" is Rosaliz — so the booth dragged those in by hand
every week. Now:
- Several equally good library photos is not a reason to use PCO's; the
  first of them is used. PCO's headshot is only for someone with no photo.
- Dropping a library photo onto the synced person already in that spot
  (same name, same first name, or a nickname sharing its first three
  letters) makes it their photo: they keep their Planning Center name and
  spot, and it is remembered (settings.json photoFor) for every later
  Sync. Photos already dragged in by hand for this plan are learned the
  same way on the next Sync.
- A synced person's library photo is read live, so replacing that photo
  updates the board without a re-sync.

Dragging. The editor was one scrolling column, so a photo found far down
the list had to be carried back up to its spot, and every drop redrew the
editor back to the top. The spots are now pinned in their own pane above
the photos, each pane auto-scrolls when a drag nears its edge, both keep
their scroll position through redraws, and a search box filters the
photos by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:28:49 -04:00

268 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 no library photo is a confident
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
* `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;
for (const e of entries) {
const s = scoreOf(e.name);
if (s > bestScore) { bestScore = s; best = e; }
}
if (bestScore < 60) return null; // no confident match → PCO photo
return best;
}
/**
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
* first three letters (a nickname: "Rose" → "Rosaliz").
*/
function samePerson(pcoName, libName) {
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = normalizeLabel(libName).split(' ')[0] || '';
if (a && a === b) return true;
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
}
module.exports = {
samePerson,
normalizeTeamName,
isAllowedTeamName,
bestLibraryMatch,
normalizeLabel,
normalizeToken,
labelTokens,
slotRoleText,
numbersIn,
scoreMatch,
assignMembersToSlots,
noteScore,
mixNumbers,
MIN_SCORE,
};