From 607d48dbf60e1aa25eda249a95dd565d19ae84e4 Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 23 Sep 2026 17:48:47 -0400 Subject: [PATCH] main: Planning Center birthdays, and the Test Birthdays diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcoFetchStatus reports a status instead of throwing, so the People probe can tell "no access" from "no such record"; pcoFetchBirthday reads one person's birthdate as MM-DD, deliberately discarding the year so a volunteer's age never lands on this machine. The sync fetches one birthday per unique person, best-effort — a blocked token just yields '' and never fails the sync. --- band-lineup-display/main.js | 168 +++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) 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,