Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882a418a0f |
@@ -57,9 +57,37 @@
|
|||||||
<option value="">Choose service type</option>
|
<option value="">Choose service type</option>
|
||||||
</select>
|
</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-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>
|
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pco-status" id="pco-status">Planning Center is not connected.</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">
|
<div class="pco-position-builder">
|
||||||
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
||||||
<option value="">Choose position</option>
|
<option value="">Choose position</option>
|
||||||
|
|||||||
+167
-1
@@ -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,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "band-lineup-display",
|
"name": "band-lineup-display",
|
||||||
"productName": "Band Lineup",
|
"productName": "Band Lineup",
|
||||||
"version": "1.3.6",
|
"version": "1.3.10",
|
||||||
"buildNumber": "92",
|
"buildNumber": "96",
|
||||||
"description": "Stage lineup photos with mic/pack labels",
|
"description": "Stage lineup photos with mic/pack labels",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
|
|||||||
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
||||||
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
||||||
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
||||||
|
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
|
||||||
onMenuAddPhotos: (cb) => {
|
onMenuAddPhotos: (cb) => {
|
||||||
ipcRenderer.on('menu-add-photos', () => cb());
|
ipcRenderer.on('menu-add-photos', () => cb());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
|
|||||||
} else {
|
} else {
|
||||||
window.__bandLineupRendererLoaded = true;
|
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 {{ personId: string, label: string }} StageSlot
|
||||||
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
|
|||||||
let pcoPositionOptions = [];
|
let pcoPositionOptions = [];
|
||||||
let slotLabelMemory = [];
|
let slotLabelMemory = [];
|
||||||
let slotNameMemory = [];
|
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[]} */
|
/** @type {StageSlot[]} */
|
||||||
let stageSlots = createEmptySlots();
|
let stageSlots = createEmptySlots();
|
||||||
const imageUrlCache = new Map();
|
const imageUrlCache = new Map();
|
||||||
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
|
|||||||
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
||||||
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
||||||
const btnPcoSync = document.getElementById('btn-pco-sync');
|
const btnPcoSync = document.getElementById('btn-pco-sync');
|
||||||
|
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
|
||||||
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
|
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 btnPcoAddPosition = document.getElementById('btn-pco-add-position');
|
||||||
const inputLayoutCols = document.getElementById('layout-cols');
|
const inputLayoutCols = document.getElementById('layout-cols');
|
||||||
const inputLayoutRows = document.getElementById('layout-rows');
|
const inputLayoutRows = document.getElementById('layout-rows');
|
||||||
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
|
|||||||
function setPcoControlsEnabled(enabled) {
|
function setPcoControlsEnabled(enabled) {
|
||||||
btnPcoConnect.disabled = !enabled;
|
btnPcoConnect.disabled = !enabled;
|
||||||
btnPcoSync.disabled = !enabled;
|
btnPcoSync.disabled = !enabled;
|
||||||
|
btnPcoTestBirthdays.disabled = !enabled;
|
||||||
btnPcoDisconnect.disabled = !enabled;
|
btnPcoDisconnect.disabled = !enabled;
|
||||||
btnPcoAddPosition.disabled = !enabled;
|
btnPcoAddPosition.disabled = !enabled;
|
||||||
pcoServiceTypeSelect.disabled = !enabled;
|
pcoServiceTypeSelect.disabled = !enabled;
|
||||||
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
|
|||||||
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 : '',
|
||||||
|
// 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);
|
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
|
||||||
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
|
|||||||
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
||||||
renderPcoDisplayPositions();
|
renderPcoDisplayPositions();
|
||||||
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
|
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() {
|
function updateSlotSizeReadout() {
|
||||||
@@ -528,6 +547,7 @@ function scheduleSave() {
|
|||||||
slotLabels: slotLabelMemory,
|
slotLabels: slotLabelMemory,
|
||||||
slotNames: slotNameMemory,
|
slotNames: slotNameMemory,
|
||||||
lineup: stageSlots,
|
lineup: stageSlots,
|
||||||
|
birthdayMode,
|
||||||
});
|
});
|
||||||
}, 250);
|
}, 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) {
|
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
||||||
void parallaxAssets;
|
void parallaxAssets;
|
||||||
const tile = document.createElement('article');
|
const tile = document.createElement('article');
|
||||||
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
|||||||
|
|
||||||
photoArea.appendChild(nameBadge);
|
photoArea.appendChild(nameBadge);
|
||||||
photoArea.appendChild(removeBtn);
|
photoArea.appendChild(removeBtn);
|
||||||
|
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
|
||||||
tile.appendChild(photoArea);
|
tile.appendChild(photoArea);
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
|
|||||||
async function renderAll() {
|
async function renderAll() {
|
||||||
await renderStage();
|
await renderStage();
|
||||||
await renderLibrary();
|
await renderLibrary();
|
||||||
|
renderBirthdayTestOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importNewPeople(added) {
|
async function importNewPeople(added) {
|
||||||
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
|
|||||||
source: 'local',
|
source: 'local',
|
||||||
pcoPersonId: '',
|
pcoPersonId: '',
|
||||||
pcoPlanPersonId: '',
|
pcoPlanPersonId: '',
|
||||||
|
birthday: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await renderLibrary();
|
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 ---------------- */
|
/* ---------------- Setup panel tabs ---------------- */
|
||||||
|
|
||||||
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
|
/** 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.
|
// 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 === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
|
||||||
|
if (name === 'pco') renderBirthdayTestOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
||||||
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
|
|||||||
void syncUpcomingPlanFromPco();
|
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 () => {
|
btnPcoDisconnect.addEventListener('click', async () => {
|
||||||
revealChrome();
|
revealChrome();
|
||||||
setPcoControlsEnabled(false);
|
setPcoControlsEnabled(false);
|
||||||
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
|
|||||||
renderPcoPositionOptions();
|
renderPcoPositionOptions();
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
setPcoStatus('Planning Center disconnected.');
|
setPcoStatus('Planning Center disconnected.');
|
||||||
|
setPcoBirthdayResult('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -331,6 +331,105 @@ body {
|
|||||||
color: #9ea7bc;
|
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 {
|
.pco-position-builder {
|
||||||
display: none;
|
display: none;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -624,6 +723,33 @@ body {
|
|||||||
font-weight: 500;
|
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 {
|
.tile .remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.4.2",
|
"version": "0.4.1",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
const { createSplListener } = require('./spl-listener');
|
||||||
|
|
||||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||||
const CALIBRATOR_DB = 94;
|
const CALIBRATOR_DB = 94;
|
||||||
@@ -114,19 +114,11 @@ module.exports = {
|
|||||||
error: '',
|
error: '',
|
||||||
streams: new Set(),
|
streams: new Set(),
|
||||||
silentSince: null,
|
silentSince: null,
|
||||||
toolMissing: '',
|
|
||||||
listener: null,
|
listener: null,
|
||||||
lastSent: '',
|
lastSent: '',
|
||||||
pinger: null,
|
pinger: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Before anything else: the device picker shells out to this tool, so a
|
|
||||||
// machine that installed the module from the catalog (source only — the
|
|
||||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
|
||||||
// listener would never happen: no tool means no device list, and no
|
|
||||||
// device means no listener.
|
|
||||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
@@ -185,9 +177,6 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
stop() { teardown(); },
|
stop() { teardown(); },
|
||||||
health() {
|
health() {
|
||||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
|
||||||
return { status: 'error', message: state.toolMissing };
|
|
||||||
}
|
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
const quiet = silentMs();
|
const quiet = silentMs();
|
||||||
@@ -311,13 +300,6 @@ module.exports = {
|
|||||||
/** Device list for the admin page. Runs the capture tool's --list,
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
which is the only thing that knows what CoreAudio can see. */
|
which is the only thing that knows what CoreAudio can see. */
|
||||||
'GET /devices': async (req, res) => {
|
'GET /devices': async (req, res) => {
|
||||||
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
|
||||||
if (missing) {
|
|
||||||
if (state) state.toolMissing = missing;
|
|
||||||
log(`device list unavailable — ${missing}`);
|
|
||||||
json(res, 200, { options: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const devices = await listDevices();
|
const devices = await listDevices();
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
@@ -456,10 +438,6 @@ function listDevices() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fsExists(p) {
|
|
||||||
try { return require('fs').existsSync(p); } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(res, code, body) {
|
function json(res, code, body) {
|
||||||
const text = JSON.stringify(body);
|
const text = JSON.stringify(body);
|
||||||
res.writeHead(code, {
|
res.writeHead(code, {
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||||
* the app, or copied from any Mac of the same architecture. When it is
|
* the app, or copied from any Mac of the same architecture. When it is
|
||||||
* missing this will build it, but in the background and never on the event
|
* missing this will build it, but in the background and never on the event
|
||||||
* loop, and from module scope so the device picker can trigger it too — see
|
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||||
* ensureCaptureBinary for what both of those cost the first time round.
|
|
||||||
*
|
*
|
||||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||||
@@ -31,74 +30,6 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
|
||||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
|
||||||
+ 'and copy it next to spl-capture.swift';
|
|
||||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
|
||||||
* everything that needs it.
|
|
||||||
*
|
|
||||||
* This lives at module scope rather than inside a listener because of a trap
|
|
||||||
* the first version walked into: the admin page's device picker runs the tool
|
|
||||||
* with --list, but the build only ran when a listener started, and a listener
|
|
||||||
* only starts once a device has been picked. On a machine that got the module
|
|
||||||
* from the catalog — source only, the binary being gitignored — the picker
|
|
||||||
* was empty, and nothing would ever fill it.
|
|
||||||
*
|
|
||||||
* It also must never block. It was a spawnSync once, copied from the LTC
|
|
||||||
* listener where the source is a third the size and builds in seconds. Here
|
|
||||||
* it took nearly two minutes on the production Mac and held the Node event
|
|
||||||
* loop for all of it, freezing every other module, then did it again on the
|
|
||||||
* next retry.
|
|
||||||
*
|
|
||||||
* The binary should normally already be there: built beside the app, or
|
|
||||||
* copied from any Mac of the same architecture. This is the fallback.
|
|
||||||
*
|
|
||||||
* @returns {string} '' when the tool is ready, otherwise what to say.
|
|
||||||
*/
|
|
||||||
let building = false;
|
|
||||||
let buildFailed = false;
|
|
||||||
|
|
||||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
|
||||||
const bin = path.join(moduleDir, 'spl-capture');
|
|
||||||
if (fs.existsSync(bin)) return '';
|
|
||||||
const src = bin + '.swift';
|
|
||||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
|
||||||
if (building) return BUILDING;
|
|
||||||
if (buildFailed) return BUILD_HELP;
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
log('spl-capture binary missing — building it in the background');
|
|
||||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
let buildErr = '';
|
|
||||||
build.stderr.setEncoding('utf8');
|
|
||||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
|
||||||
const giveUp = setTimeout(() => {
|
|
||||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
|
||||||
}, 300000);
|
|
||||||
build.on('error', (err) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false; buildFailed = true;
|
|
||||||
log(`swiftc could not be run: ${err.message}`);
|
|
||||||
});
|
|
||||||
build.on('exit', (code) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
if (code === 0 && fs.existsSync(bin)) {
|
|
||||||
log('spl-capture built');
|
|
||||||
buildFailed = false;
|
|
||||||
if (typeof onReady === 'function') onReady();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = true;
|
|
||||||
log(`spl-capture build failed (exit ${code})`
|
|
||||||
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
|
||||||
});
|
|
||||||
return BUILDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||||
retrying every 5 s would only fill the log with the same sentence. */
|
retrying every 5 s would only fill the log with the same sentence. */
|
||||||
@@ -122,13 +53,64 @@ function createSplListener(opts) {
|
|||||||
let stallTimer = null;
|
let stallTimer = null;
|
||||||
let startedAt = 0;
|
let startedAt = 0;
|
||||||
let meta = null;
|
let meta = null;
|
||||||
|
let building = false;
|
||||||
|
let buildFailed = false;
|
||||||
let lastFrameWall = 0;
|
let lastFrameWall = 0;
|
||||||
|
|
||||||
/** '' when the tool is ready, otherwise what to say about it. */
|
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||||
|
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||||
|
+ 'and copy it next to spl-capture.swift';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||||
|
*
|
||||||
|
* This used to be a spawnSync, copied from the LTC listener where the
|
||||||
|
* source is a third the size and builds in a few seconds. Here it took
|
||||||
|
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||||
|
* server for every second of it — one module's optional helper took the
|
||||||
|
* whole dashboard down, then did it again on every retry. Nothing that
|
||||||
|
* runs while a service might be on is allowed to hold the event loop.
|
||||||
|
*
|
||||||
|
* The build is a fallback for a module installed from the catalog on a
|
||||||
|
* machine with a Swift toolchain. The normal path is a binary built
|
||||||
|
* alongside the app, or copied from any same-architecture Mac.
|
||||||
|
*
|
||||||
|
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||||
|
*/
|
||||||
function ensureBinary() {
|
function ensureBinary() {
|
||||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
if (fs.existsSync(bin)) return '';
|
||||||
|
const src = bin + '.swift';
|
||||||
|
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||||
|
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||||
|
if (buildFailed) return BUILD_HELP;
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
log('spl-capture binary missing — building it in the background');
|
||||||
|
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let buildErr = '';
|
||||||
|
build.stderr.setEncoding('utf8');
|
||||||
|
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||||
|
const giveUp = setTimeout(() => {
|
||||||
|
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||||
|
}, 300000);
|
||||||
|
build.on('error', (err) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false; buildFailed = true;
|
||||||
|
log(`swiftc could not be run: ${err.message}`);
|
||||||
|
});
|
||||||
|
build.on('exit', (code) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
if (code === 0 && fs.existsSync(bin)) {
|
||||||
|
log('spl-capture built');
|
||||||
|
buildFailed = false;
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||||
} });
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = true;
|
||||||
|
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||||
|
});
|
||||||
|
return 'Building the capture tool — this takes a minute or two';
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRestart(ms) {
|
function scheduleRestart(ms) {
|
||||||
@@ -155,7 +137,7 @@ function createSplListener(opts) {
|
|||||||
onUpdate({ error: missing });
|
onUpdate({ error: missing });
|
||||||
// A build in flight calls start() itself when it lands; anything else
|
// A build in flight calls start() itself when it lands; anything else
|
||||||
// is config-shaped and should back off hard rather than spin.
|
// is config-shaped and should back off hard rather than spin.
|
||||||
if (missing !== BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,4 +236,4 @@ function createSplListener(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSplListener, ensureCaptureBinary };
|
module.exports = { createSplListener };
|
||||||
|
|||||||
Reference in New Issue
Block a user