From d1962f78d882a6c1dd88fe5288c4500163db235a Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 23 Sep 2026 17:55:03 -0400 Subject: [PATCH] renderer: the celebration itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wednesday→Tuesday birthday week (cached, and re-rendered when it rolls over so an app left open for days lights up on its own), the canvas confetti loop, the gold glow, and the Birthday Mode controls — on/off, intensity, and a force-on picker for testing that lives in memory and clears on restart. The confetti deliberately ignores OS reduced-motion: it is a celebration the operator opts into, and freezing it leaves confetti stuck mid-fall. --- band-lineup-display/renderer.js | 288 +++++++++++++++++++++++++++++++- 1 file changed, 280 insertions(+), 8 deletions(-) diff --git a/band-lineup-display/renderer.js b/band-lineup-display/renderer.js index f756de2..dd91c5d 100644 --- a/band-lineup-display/renderer.js +++ b/band-lineup-display/renderer.js @@ -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 = ''; + 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) ---------------- */ @@ -1330,11 +1571,11 @@ function renderDisplay() { } if (!state?.enabled) { - setDisplayStatus('Off \u2014 screens around the building can\u2019t see this yet.'); + setDisplayStatus('Off — screens around the building can’t see this yet.'); } else if (state.lastError) { setDisplayStatus(state.lastError, true); } else if (!state.listening) { - setDisplayStatus('Starting\u2026'); + setDisplayStatus('Starting…'); } else if (state.mdnsConflict) { setDisplayStatus( `Another computer is already publishing that name (${state.mdnsConflict}). Use one of the numeric addresses below.`, @@ -1346,19 +1587,19 @@ function renderDisplay() { // when two machines are open is just wrong. const screens = state.devices || state.viewers; const extra = state.viewers > screens ? ` (${state.viewers} connections)` : ''; - setDisplayStatus(`On \u2014 ${screens} screen${screens === 1 ? '' : 's'} watching${extra}.`); + setDisplayStatus(`On — ${screens} screen${screens === 1 ? '' : 's'} watching${extra}.`); } else if (state.everHadViewer) { - setDisplayStatus('On \u2014 no screens open right now.'); + setDisplayStatus('On — no screens open right now.'); } else if (displayOnSince && Date.now() - displayOnSince > DISPLAY_FIREWALL_GRACE_MS) { // On Windows the firewall prompt fires when the server starts listening, not when a // studio first connects — so localhost looks fine here while every other screen times // out. A viewer count stuck at zero is the only hint we get; say it plainly. setDisplayStatus( - `On, but no screen has connected yet. If a studio can\u2019t load the page, Windows Firewall on this computer is probably blocking Band Lineup \u2014 it needs to allow TCP port ${state.port} for Private networks.`, + `On, but no screen has connected yet. If a studio can’t load the page, Windows Firewall on this computer is probably blocking Band Lineup — it needs to allow TCP port ${state.port} for Private networks.`, true, ); } else { - setDisplayStatus('On \u2014 open one of these on any screen in the building.'); + setDisplayStatus('On — open one of these on any screen in the building.'); } renderDisplayUrls(); @@ -1385,7 +1626,7 @@ function scheduleDisplayRefresh() { async function toggleDisplayHosting() { const next = !displayState?.enabled; btnDisplayToggle.disabled = true; - setDisplayStatus(next ? 'Starting\u2026' : 'Stopping\u2026'); + setDisplayStatus(next ? 'Starting…' : 'Stopping…'); try { displayState = await window.lineup.display.setEnabled(next); displayOnSince = next ? Date.now() : 0; @@ -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 {