diff --git a/README.md b/README.md
index fc3e1fb..5a26aa0 100644
--- a/README.md
+++ b/README.md
@@ -365,15 +365,24 @@ below. Nothing is installed without someone clicking.
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
-- **Band Lineup** — the backstage stage board: 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. Guests and MCs not on the plan are pinned
- per slot from the title bar's Edit button. Admin config: Planning Center
- credentials, service type and plan, the teams to include, the stage slots
- (one mic/pack label per line, in stage order), default columns, and a
- Google Drive folder for manual photos. Tile settings: names, mic labels,
- parallax, and the birthday confetti with its intensity.
+- **Band Lineup** — the backstage stage board: everyone on stage in their mic
+ slot with photo, name and mic/pack label, on one shared board that every
+ screen sees the same way — whoever edits it changes it everywhere, over SSE.
+ A photo library fills it: point the module at a Google Drive folder shared
+ "anyone with the link" (file name → person's name) or at a folder on the
+ ProdDash machine, and photos added or removed there follow. **Edit** in the
+ title bar opens that library — drag a photo onto a slot, drag between slots
+ to swap, rename or clear. Planning Center is optional auto-fill on top, not
+ the source of truth: **Sync** reads the upcoming plan's team members and
+ drops each into the slot matching their position, filling only slots nobody
+ placed by hand, so a guest pinned on Thursday survives Sunday's sync.
+ Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
+ raining confetti, and a Choir button adds the extra spot for the weeks there
+ is one. Admin config: Planning Center credentials, service type and plan, the
+ teams to include, the stage slots (one mic/pack label per line, in stage
+ order), default columns, and the photo folder. Tile settings: parallax,
+ names, mic labels, and birthday confetti with its intensity and a test
+ picker — columns are − / + in the title bar.
- **Service Times** — every campus's Sunday service times, read from the
church website and re-read on a timer, so the board follows the site when
the times change through the year. Any service can be marked for
diff --git a/band-lineup-display/index.html b/band-lineup-display/index.html
index d70b96b..0d23c3e 100644
--- a/band-lineup-display/index.html
+++ b/band-lineup-display/index.html
@@ -57,9 +57,37 @@
Choose service type
Sync Upcoming
+ Test Birthdays
Disconnect
Planning Center is not connected.
+
+
+
+
+ Birthday Mode 🎂
+
+
+ On
+
+
+
+
+
Intensity
+
+ Subtle
+ Full
+
+
+
+ Force on (test)
+
+ Off
+
+
+
Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.
+
+
Choose position
diff --git a/band-lineup-display/main.js b/band-lineup-display/main.js
index badcc16..4d27d92 100644
--- a/band-lineup-display/main.js
+++ b/band-lineup-display/main.js
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
return payload || { data: [] };
}
+/**
+ * Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell
+ * "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People
+ * birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do.
+ */
+async function pcoFetchStatus(target, credentials, searchParams) {
+ const url = buildPcoUrl(target, searchParams);
+ const response = await fetch(url, {
+ headers: {
+ Accept: 'application/json',
+ Authorization: pcoAuthHeader(credentials),
+ 'User-Agent': pcoUserAgent(),
+ },
+ });
+ const text = await response.text();
+ let payload = null;
+ try {
+ payload = text ? JSON.parse(text) : null;
+ } catch {
+ payload = null;
+ }
+ return { status: response.status, ok: response.ok, payload };
+}
+
+/**
+ * Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People
+ * share the same person id, so the Services person id we already store doubles as the People id.
+ * Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent.
+ */
+async function pcoFetchBirthday(personId, credentials) {
+ if (!personId) return { status: 0, birthday: '', fieldPresent: false };
+ const { status, ok, payload } = await pcoFetchStatus(
+ `/people/v2/people/${encodeURIComponent(personId)}`,
+ credentials,
+ { 'fields[Person]': 'birthdate' },
+ );
+ if (!ok) return { status, birthday: '', fieldPresent: false };
+ const attrs = payload?.data?.attributes || {};
+ return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs };
+}
+
async function pcoFetchAll(target, credentials, searchParams) {
let nextUrl = buildPcoUrl(target, searchParams).toString();
const data = [];
@@ -275,6 +316,15 @@ function emptyState() {
pcoDisplayPositions: [],
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
+ birthdayMode: { enabled: true, intensity: 'subtle' },
+ };
+}
+
+/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
+function normalizeBirthdayMode(entry) {
+ return {
+ enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
+ intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
};
}
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
+ // "MM-DD" only — we deliberately never persist the birth year (month + day is all
+ // Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file.
+ birthday: normalizeBirthdayMmdd(person?.birthday),
};
}
+/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */
+function normalizeBirthdayMmdd(raw) {
+ const s = String(raw || '').trim();
+ const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape
+ if (full) return `${full[1]}-${full[2]}`;
+ const md = s.match(/^(\d{2})-(\d{2})$/);
+ if (md) {
+ const mm = Number(md[1]);
+ const dd = Number(md[2]);
+ if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`;
+ }
+ return '';
+}
+
function normalizeTeamName(value) {
return String(value || '').trim().toLowerCase();
}
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
});
+ // Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a
+ // person with no date on file just yields '' and never fails the sync). Fetched once per unique
+ // person so a member in two positions isn't queried twice.
+ const birthdayByPerson = new Map();
+ for (const member of scheduledMembers) {
+ if (!member.personId || birthdayByPerson.has(member.personId)) continue;
+ try {
+ const { birthday } = await pcoFetchBirthday(member.personId, credentials);
+ birthdayByPerson.set(member.personId, birthday || '');
+ } catch {
+ birthdayByPerson.set(member.personId, '');
+ }
+ }
+
const oldPcoFiles = new Set(
(Array.isArray(baseState.library) ? baseState.library : [])
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
source: 'pco',
pcoPersonId: member.personId,
pcoPlanPersonId: member.planPersonId,
+ birthday: birthdayByPerson.get(member.personId) || '',
});
}
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
const atWriteTime = await loadStateRaw();
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
+ nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
await saveStateRaw(nextState);
for (const fileName of oldPcoFiles) {
@@ -1222,6 +1305,7 @@ function migrateState(data) {
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
proPresenter: normalizeProPresenterConfig(data.proPresenter),
displayServer: normalizeDisplayServerConfig(data.displayServer),
+ birthdayMode: normalizeBirthdayMode(data.birthdayMode),
lineup: data.lineup.map((e) => {
if (!e || typeof e !== 'object') return { personId: '', label: '' };
return {
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
// The renderer never edits this (it is changed through the display-* IPC handlers), so
// always carry the stored value forward rather than letting a save blank it out.
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
- await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
+ const birthdayMode = payload?.birthdayMode
+ ? normalizeBirthdayMode(payload.birthdayMode)
+ : normalizeBirthdayMode(existing.birthdayMode);
+ await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
return true;
});
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
return syncUpcomingPcoPlan(payload);
});
+/**
+ * Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
+ * People API and read birthdates, and how many synced band members have one on file. Read-only.
+ */
+ipcMain.handle('pco-test-birthdays', async () => {
+ const credentials = await loadPcoCredentials();
+ if (!credentials) return { connected: false };
+
+ // 1. Can the token read the People API at all?
+ let probe;
+ try {
+ probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
+ } catch (err) {
+ return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
+ }
+ if (!probe.ok) {
+ const detail =
+ probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
+ `${probe.status}`;
+ return { connected: true, canRead: false, status: probe.status, detail };
+ }
+ const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
+ const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
+
+ // 2. Coverage across the people we've actually synced (unique by person id).
+ const state = await loadStateRaw();
+ const seen = new Set();
+ const targets = [];
+ for (const p of Array.isArray(state.library) ? state.library : []) {
+ if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
+ seen.add(p.pcoPersonId);
+ targets.push(p);
+ }
+
+ let withBirthday = 0;
+ let notFound = 0;
+ let errors = 0;
+ const found = [];
+ for (const p of targets) {
+ let r;
+ try {
+ r = await pcoFetchBirthday(p.pcoPersonId, credentials);
+ } catch {
+ errors += 1;
+ continue;
+ }
+ if (r.status === 404) { notFound += 1; continue; }
+ if (r.status && r.status >= 400) { errors += 1; continue; }
+ if (r.birthday) {
+ withBirthday += 1;
+ found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
+ }
+ }
+
+ // Soonest upcoming birthday, for a concrete "it works" line.
+ const today = new Date();
+ const daysUntil = (mmdd) => {
+ const [mm, dd] = mmdd.split('-').map(Number);
+ let next = new Date(today.getFullYear(), mm - 1, dd);
+ const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+ if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
+ return Math.round((next - midnight) / 86400000);
+ };
+ const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
+ const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
+
+ return {
+ connected: true,
+ canRead: true,
+ fieldExposed,
+ total: targets.length,
+ withBirthday,
+ notFound,
+ errors,
+ found: sortedFound.slice(0, 8),
+ nextUp,
+ };
+});
+
ipcMain.handle('pp-status', async () => {
return {
enabled: ppConfig.enabled,
diff --git a/band-lineup-display/package.json b/band-lineup-display/package.json
index 209618c..5bffa80 100644
--- a/band-lineup-display/package.json
+++ b/band-lineup-display/package.json
@@ -1,8 +1,8 @@
{
"name": "band-lineup-display",
"productName": "Band Lineup",
- "version": "1.3.6",
- "buildNumber": "92",
+ "version": "1.3.10",
+ "buildNumber": "96",
"description": "Stage lineup photos with mic/pack labels",
"main": "main.js",
"scripts": {
diff --git a/band-lineup-display/preload.js b/band-lineup-display/preload.js
index 627a24f..f999761 100644
--- a/band-lineup-display/preload.js
+++ b/band-lineup-display/preload.js
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
+ pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
onMenuAddPhotos: (cb) => {
ipcRenderer.on('menu-add-photos', () => cb());
},
diff --git a/band-lineup-display/renderer.js b/band-lineup-display/renderer.js
index f756de2..634d634 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 = 'Off ';
+ 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 {
diff --git a/band-lineup-display/styles.css b/band-lineup-display/styles.css
index 256ccef..a53169f 100644
--- a/band-lineup-display/styles.css
+++ b/band-lineup-display/styles.css
@@ -331,6 +331,105 @@ body {
color: #9ea7bc;
}
+.pco-birthday-result {
+ min-height: 0;
+ line-height: 1.45;
+}
+
+.pco-birthday-result:empty {
+ display: none;
+}
+
+/* ---- Birthday Mode settings ---- */
+.bday-block {
+ margin-top: 14px;
+ border-top: 1px solid #23232c;
+ padding-top: 12px;
+}
+.bday-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.bday-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #e7e7ef;
+}
+.bday-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ font-size: 12px;
+ color: #9ea7bc;
+}
+.bday-controls {
+ margin-top: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.bday-line {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.bday-line-label {
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #8a8a99;
+ min-width: 74px;
+}
+.bday-line-label em {
+ font-style: normal;
+ text-transform: none;
+ letter-spacing: 0;
+ color: #6d6d7a;
+}
+.seg-inline {
+ display: inline-flex;
+ gap: 4px;
+ background: #0e0e14;
+ border: 1px solid #23232c;
+ border-radius: 9px;
+ padding: 3px;
+}
+.seg-inline button {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: #9ea7bc;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 5px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.seg-inline button[aria-pressed="true"] {
+ background: #f5c451;
+ color: #241a02;
+}
+.bday-check {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: #c6cada;
+ cursor: pointer;
+}
+.bday-test-select {
+ flex: 1;
+ min-width: 150px;
+}
+.bday-hint {
+ font-size: 11px;
+ color: #6d6d7a;
+}
+
.pco-position-builder {
display: none;
grid-template-columns: minmax(0, 1fr) auto;
@@ -624,6 +723,33 @@ body {
font-weight: 500;
}
+/* ---- Birthday Mode tile effects ---- */
+.tile.birthday-subtle {
+ animation: bdayGlowSubtle 3.4s ease-in-out infinite;
+}
+.tile.birthday-full {
+ animation: bdayGlowFull 2.2s ease-in-out infinite;
+}
+@keyframes bdayGlowSubtle {
+ 0%, 100% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.55), 0 0 14px rgba(245, 196, 81, 0.18); }
+ 50% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.85), 0 0 20px rgba(245, 196, 81, 0.32); }
+}
+@keyframes bdayGlowFull {
+ 0%, 100% { box-shadow: inset 0 0 0 3px rgba(245, 196, 81, 0.8), 0 0 20px rgba(245, 196, 81, 0.42); }
+ 50% { box-shadow: inset 0 0 0 3px #ffd873, 0 0 34px rgba(245, 196, 81, 0.66); }
+}
+
+/* Birthday Mode intentionally keeps animating even under OS "reduced motion" — it's a celebration
+ the operator opts into, and freezing it leaves confetti stuck mid-fall. */
+
+.birthday-confetti {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+}
+
+
.tile .remove {
position: absolute;
top: 6px;
diff --git a/modules/band-lineup/README.md b/modules/band-lineup/README.md
deleted file mode 100644
index eca1f6d..0000000
--- a/modules/band-lineup/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Band Lineup module
-
-The backstage stage board as a ProdDash tile: everyone scheduled on the band /
-vocal / production teams for the upcoming Planning Center plan, placed into their
-mic slot with photo, name and mic/pack label. People whose birthday falls in the
-current service week (Wednesday→Tuesday) get raining confetti and a gold glow.
-
-This is the display half of the standalone **Band Lineup** Electron app, ported
-to ProdDash. (The ProPresenter Now/Next half of that app is the separate
-`propresenter-now-next` module.)
-
-## How it fills the board
-
-1. **Slots** are the mic/pack labels you write in admin, one per line, in the
- order they sit on stage — e.g. `[VOX 2] MIX 2`, `[DRUMS]`, `[MC] MC1`. A
- blank line is an empty spot.
-2. Each refresh the server reads the upcoming plan's **team members** (any team
- whose name matches a line in *Teams to include*), and matches each person to
- a slot by their Planning Center **position** — the same loose matching the
- Electron app uses (`matching.js`; `[VOX 2] MIX 2` takes whoever is on *Vox
- 2*). A numbered role only matches the same number, and the `MIX n` part is
- ignored, so Vox 2 and Vox 3 never swap.
-3. **Photos and birthdays** come from Planning Center (birthdays via the People
- API; only month + day is kept). Photos are cached on disk and served by the
- module; birthdays drive the confetti.
-
-## Manual photos (people not on the plan)
-
-Guest speakers, MCs, stage managers — anyone not scheduled in Planning Center —
-are pinned per slot from the tile's **Edit** button (title bar): set a name,
-optionally upload a photo, optionally a `MM-DD` birthday. A manual pin overrides
-the PCO match for that slot and is kept until you Clear it. Uploaded photos live
-in the ProdDash data directory, never in the repo.
-
-## Settings
-
-- **Admin** (server-wide): Planning Center credentials, service type, plan, the
- teams to include, the stage slots, columns, refresh interval.
-- **Per tile** (gear): birthday confetti on/off + intensity, a force-birthday
- test picker, parallax, and show/hide for names and mic labels.
-
-## Testing without Planning Center
-
-```bash
-node tools/pco-band-mock.js 24710 # mock PCO
-BAND_LINEUP_API_BASE=http://127.0.0.1:24710 node server.js # point the module at it
-node modules/band-lineup/matching.test.js # slot-matching unit tests
-```
-
-Then in `/admin` under Band Lineup enter any Client ID + Secret, pick "Sunday
-Service", and the board fills — one person (on Drums) has a birthday this week,
-so their tile rains confetti.