main: Planning Center birthdays, and the Test Birthdays diagnostic

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.
This commit is contained in:
2026-09-23 17:48:47 -04:00
parent ffbefe7a49
commit 607d48dbf6
+167 -1
View File
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
return payload || { data: [] }; 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) { async function pcoFetchAll(target, credentials, searchParams) {
let nextUrl = buildPcoUrl(target, searchParams).toString(); let nextUrl = buildPcoUrl(target, searchParams).toString();
const data = []; const data = [];
@@ -275,6 +316,15 @@ function emptyState() {
pcoDisplayPositions: [], pcoDisplayPositions: [],
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' }, proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT }, 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', source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '', pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '', 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) { function normalizeTeamName(value) {
return String(value || '').trim().toLowerCase(); return String(value || '').trim().toLowerCase();
} }
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }); 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( const oldPcoFiles = new Set(
(Array.isArray(baseState.library) ? baseState.library : []) (Array.isArray(baseState.library) ? baseState.library : [])
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string') .filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
source: 'pco', source: 'pco',
pcoPersonId: member.personId, pcoPersonId: member.personId,
pcoPlanPersonId: member.planPersonId, pcoPlanPersonId: member.planPersonId,
birthday: birthdayByPerson.get(member.personId) || '',
}); });
} }
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
const atWriteTime = await loadStateRaw(); const atWriteTime = await loadStateRaw();
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter); nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer); nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
await saveStateRaw(nextState); await saveStateRaw(nextState);
for (const fileName of oldPcoFiles) { for (const fileName of oldPcoFiles) {
@@ -1222,6 +1305,7 @@ function migrateState(data) {
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions), pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
proPresenter: normalizeProPresenterConfig(data.proPresenter), proPresenter: normalizeProPresenterConfig(data.proPresenter),
displayServer: normalizeDisplayServerConfig(data.displayServer), displayServer: normalizeDisplayServerConfig(data.displayServer),
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
lineup: data.lineup.map((e) => { lineup: data.lineup.map((e) => {
if (!e || typeof e !== 'object') return { personId: '', label: '' }; if (!e || typeof e !== 'object') return { personId: '', label: '' };
return { 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 // 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. // always carry the stored value forward rather than letting a save blank it out.
const displayServer = normalizeDisplayServerConfig(existing.displayServer); 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; return true;
}); });
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
return syncUpcomingPcoPlan(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 () => { ipcMain.handle('pp-status', async () => {
return { return {
enabled: ppConfig.enabled, enabled: ppConfig.enabled,