Band Lineup Display snapshot catches up: 1.3.6 → 1.3.10

The reference fork had drifted four builds behind the app actually running
backstage. What it was missing is Birthday Mode, the feature the ProdDash
module was ported with and this snapshot never had:

- main.js grows the Planning Center birthday fetch (`pcoFetchBirthday`),
  mm-dd normalising, and a `pco-test-birthdays` IPC handler behind the
  settings pane's Test Birthdays button.
- renderer.js gains the whole celebration: the Wednesday→Tuesday birthday
  week, the canvas confetti loop, the gold glow, and the controls that switch
  it on and set its intensity.
- index.html and styles.css carry the Birthday Mode block in settings.

Purely additive — 597 lines in, four out, and those four are the version and
build number. Source only, as the fork has always been: `.claude/` settings
stay as they are (the local copy's are one machine's accumulated permissions,
and it has no launch.json to give), and the stray test file beside them is not
part of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:37:54 -04:00
co-authored by Claude Opus 5
parent 0e618ea5d3
commit 882a418a0f
6 changed files with 597 additions and 4 deletions
+273 -1
View File
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
} else {
window.__bandLineupRendererLoaded = true;
/**
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
* @typedef {{ personId: string, label: string }} StageSlot
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
*/
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
let pcoPositionOptions = [];
let slotLabelMemory = [];
let slotNameMemory = [];
/** Birthday Mode display settings (persisted). */
let birthdayMode = { enabled: true, intensity: 'subtle' };
/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */
let birthdayTestPersonId = '';
/** @type {StageSlot[]} */
let stageSlots = createEmptySlots();
const imageUrlCache = new Map();
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
const btnPcoConnect = document.getElementById('btn-pco-connect');
const btnPcoSync = document.getElementById('btn-pco-sync');
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
const pcoBirthdayResult = document.getElementById('pco-birthday-result');
const bdayEnabled = document.getElementById('bday-enabled');
const bdayIntensity = document.getElementById('bday-intensity');
const bdayControls = document.getElementById('bday-controls');
const bdayTestSelect = document.getElementById('bday-test');
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
const inputLayoutCols = document.getElementById('layout-cols');
const inputLayoutRows = document.getElementById('layout-rows');
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
function setPcoControlsEnabled(enabled) {
btnPcoConnect.disabled = !enabled;
btnPcoSync.disabled = !enabled;
btnPcoTestBirthdays.disabled = !enabled;
btnPcoDisconnect.disabled = !enabled;
btnPcoAddPosition.disabled = !enabled;
pcoServiceTypeSelect.disabled = !enabled;
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
// Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js
// re-validates the "MM-DD" shape on write); Birthday Mode reads this.
birthday: typeof person?.birthday === 'string' ? person.birthday : '',
}))
: [];
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
pcoServiceTypeSelect.value = pcoServiceTypeId;
renderPcoDisplayPositions();
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
birthdayMode = {
enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled),
intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle',
};
syncBirthdayControls();
}
function updateSlotSizeReadout() {
@@ -528,6 +547,7 @@ function scheduleSave() {
slotLabels: slotLabelMemory,
slotNames: slotNameMemory,
lineup: stageSlots,
birthdayMode,
});
}, 250);
}
@@ -950,6 +970,132 @@ function parallaxMotionVars(seedText) {
};
}
/* ---------------- Birthday Mode ---------------- */
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
const birthdayCanvases = new Set();
let birthdayRaf = null;
/**
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
* anywhere in this window celebrates all week (so it's already up for the weekend services).
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
*/
let birthdayWeekCache = { key: '', set: null };
function birthdayWeekSet() {
const now = new Date();
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
const set = new Set();
for (let i = 0; i < 7; i += 1) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
birthdayWeekCache = { key, set };
return set;
}
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
function isBirthdayActive(person) {
if (!birthdayMode.enabled || !person) return false;
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
}
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
function birthdayTick() {
const before = birthdayWeekCache.key;
birthdayWeekSet();
if (before && birthdayWeekCache.key !== before) void renderStage();
}
setInterval(birthdayTick, 15 * 60 * 1000);
function birthdayMakeParticle(w, h, intensity, seedY) {
const ribbon = Math.random() < 0.55;
return {
x: Math.random() * w,
y: seedY != null ? seedY : Math.random() * h,
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
h: ribbon ? 9 + Math.random() * 8 : 0,
ribbon,
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
rot: Math.random() * Math.PI * 2,
vr: -0.14 + Math.random() * 0.28,
sway: 0.6 + Math.random() * 1.1,
phase: Math.random() * Math.PI * 2,
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
};
}
function birthdaySeedCanvas(c, w, h) {
const base = c._intensity === 'subtle' ? 26 : 64;
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
}
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
function birthdayStepCanvas(c) {
const w = c.clientWidth;
const h = c.clientHeight;
if (!w || !h) return; // not laid out yet — try again next frame
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (c._w !== w || c._h !== h) {
c.width = w * dpr;
c.height = h * dpr;
c._ctx = c.getContext('2d');
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
c._w = w;
c._h = h;
birthdaySeedCanvas(c, w, h);
}
const ctx = c._ctx;
ctx.clearRect(0, 0, w, h);
for (const p of c._parts) {
p.phase += 0.03;
p.y += p.vy;
p.x += Math.sin(p.phase) * p.sway;
p.rot += p.vr;
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
}
function birthdayLoop() {
for (const c of [...birthdayCanvases]) {
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
birthdayStepCanvas(c);
}
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
}
function birthdayRegisterCanvas(canvas, intensity) {
canvas._intensity = intensity;
birthdayCanvases.add(canvas);
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
}
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
function applyBirthdayEffects(tile, photoArea) {
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
tile.classList.add('birthday', `birthday-${intensity}`);
const confetti = document.createElement('canvas');
confetti.className = 'birthday-confetti';
confetti.setAttribute('aria-hidden', 'true');
photoArea.appendChild(confetti);
birthdayRegisterCanvas(confetti, intensity);
}
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
void parallaxAssets;
const tile = document.createElement('article');
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
photoArea.appendChild(nameBadge);
photoArea.appendChild(removeBtn);
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
tile.appendChild(photoArea);
return tile;
}
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
async function renderAll() {
await renderStage();
await renderLibrary();
renderBirthdayTestOptions();
}
async function importNewPeople(added) {
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
source: 'local',
pcoPersonId: '',
pcoPlanPersonId: '',
birthday: '',
});
}
await renderLibrary();
@@ -1232,6 +1381,97 @@ async function syncUpcomingPlanFromPco() {
}
}
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function formatBirthdayMmdd(mmdd) {
const parts = String(mmdd || '').split('-');
const mm = Number(parts[0]);
const dd = Number(parts[1]);
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
}
function setPcoBirthdayResult(message, tone) {
if (!pcoBirthdayResult) return;
pcoBirthdayResult.textContent = message || '';
pcoBirthdayResult.style.display = message ? '' : 'none';
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
}
async function testPcoBirthdays() {
setPcoControlsEnabled(false);
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
try {
const r = await window.lineup.pcoTestBirthdays();
if (!r || r.connected === false) {
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
return;
}
if (!r.canRead) {
const why = r.reason === 'network'
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
return;
}
// Access works. Report coverage across synced band members.
if (r.total === 0) {
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
return;
}
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
if (r.nextUp) {
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
}
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
} catch (error) {
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
} finally {
setPcoControlsEnabled(true);
}
}
/* ---------------- Birthday Mode controls ---------------- */
/** Reflect current birthdayMode settings onto the controls (called after load). */
function syncBirthdayControls() {
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
if (bdayIntensity) {
for (const b of bdayIntensity.querySelectorAll('button')) {
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
}
}
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
}
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
function renderBirthdayTestOptions() {
if (!bdayTestSelect) return;
const previous = birthdayTestPersonId;
bdayTestSelect.innerHTML = '<option value="">Off</option>';
const seen = new Set();
stageSlots.forEach((slot, index) => {
if (!slot.personId || seen.has(slot.personId)) return;
const person = getPerson(slot.personId);
if (!person) return;
seen.add(slot.personId);
const option = document.createElement('option');
option.value = person.id;
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
const label = String(slotLabelMemory[index] || '').trim();
option.textContent = label ? `${name} — ${label}` : name;
bdayTestSelect.appendChild(option);
});
// Keep the current override selected if that person is still on stage; otherwise drop it.
if (previous && seen.has(previous)) {
bdayTestSelect.value = previous;
} else if (previous) {
birthdayTestPersonId = '';
bdayTestSelect.value = '';
}
}
/* ---------------- Setup panel tabs ---------------- */
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
@@ -1257,6 +1497,7 @@ function activatePanelTab(name) {
}
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
if (name === 'pco') renderBirthdayTestOptions();
}
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
void syncUpcomingPlanFromPco();
});
btnPcoTestBirthdays.addEventListener('click', () => {
revealChrome();
void testPcoBirthdays();
});
if (bdayEnabled) {
bdayEnabled.addEventListener('change', () => {
birthdayMode.enabled = bdayEnabled.checked;
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayIntensity) {
bdayIntensity.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-int]');
if (!btn) return;
birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle';
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayTestSelect) {
bdayTestSelect.addEventListener('change', () => {
birthdayTestPersonId = bdayTestSelect.value || '';
void renderStage();
});
}
btnPcoDisconnect.addEventListener('click', async () => {
revealChrome();
setPcoControlsEnabled(false);
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
renderPcoPositionOptions();
scheduleSave();
setPcoStatus('Planning Center disconnected.');
setPcoBirthdayResult('');
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
} finally {