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:
@@ -57,9 +57,37 @@
|
||||
<option value="">Choose service type</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
|
||||
<button type="button" class="btn btn-small" id="btn-pco-test-birthdays">Test Birthdays</button>
|
||||
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
|
||||
</div>
|
||||
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
|
||||
<div class="pco-status pco-birthday-result" id="pco-birthday-result"></div>
|
||||
|
||||
<div class="bday-block">
|
||||
<div class="bday-head">
|
||||
<span class="bday-title">Birthday Mode 🎂</span>
|
||||
<label class="bday-switch">
|
||||
<input type="checkbox" id="bday-enabled" />
|
||||
<span class="bday-switch-label">On</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bday-controls" id="bday-controls">
|
||||
<div class="bday-line">
|
||||
<span class="bday-line-label">Intensity</span>
|
||||
<div class="seg-inline" id="bday-intensity" role="group" aria-label="Birthday intensity">
|
||||
<button type="button" data-int="subtle">Subtle</button>
|
||||
<button type="button" data-int="full">Full</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bday-line">
|
||||
<span class="bday-line-label">Force on <em>(test)</em></span>
|
||||
<select id="bday-test" class="pco-select bday-test-select" aria-label="Force birthday on a person for testing">
|
||||
<option value="">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bday-hint">Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pco-position-builder">
|
||||
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
||||
<option value="">Choose position</option>
|
||||
|
||||
+167
-1
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user