Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f542b34b6c | ||
|
|
a1ea9262c6 | ||
|
|
098562c088 | ||
|
|
408dea7923 | ||
|
|
3ca960baf6 | ||
|
|
882a418a0f | ||
|
|
0e618ea5d3 | ||
|
|
c8b51c6c2f | ||
|
|
c464e5b4ca | ||
|
|
f4d50f5e95 |
@@ -365,15 +365,41 @@ below. Nothing is installed without someone clicking.
|
||||
as `Label | Message` — add `| #channel` to bind one to a channel — and up
|
||||
to eight quick emojis).
|
||||
Tile settings: avatars, timestamps, names and channel notices.
|
||||
- **Band Lineup** — the backstage stage board: everyone scheduled on the
|
||||
band, vocal and production teams for the upcoming Planning Center plan,
|
||||
placed into their mic slot with photo, name and mic/pack label; birthdays
|
||||
in the service week get confetti. Guests and MCs not on the plan are pinned
|
||||
per slot from the title bar's Edit button. Admin config: Planning Center
|
||||
credentials, service type and plan, the teams to include, the stage slots
|
||||
(one mic/pack label per line, in stage order), default columns, and a
|
||||
Google Drive folder for manual photos. Tile settings: names, mic labels,
|
||||
parallax, and the birthday confetti with its intensity.
|
||||
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
|
||||
slot with photo, name and mic/pack label, on one shared board that every
|
||||
screen sees the same way — whoever edits it changes it everywhere, over SSE.
|
||||
A photo library fills it: point the module at a Google Drive folder shared
|
||||
"anyone with the link" (file name → person's name) or at a folder on the
|
||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
teams to include, the stage slots (one mic/pack label per line, in stage
|
||||
order), default columns, and the photo folder. Tile settings: parallax,
|
||||
names, mic labels, and birthday confetti with its intensity and a test
|
||||
picker — columns are − / + in the title bar.
|
||||
- **Service Times** — every campus's Sunday service times, read from the
|
||||
church website and re-read on a timer, so the board follows the site when
|
||||
the times change through the year. Any service can be marked for
|
||||
production — issue, replay, or do not use — with a note, and a campus
|
||||
taking resi records which of the origin's services it is playing. Marking
|
||||
a broadcast service "do not use" flags every campus riding on it, which is
|
||||
the question the board exists to answer. Marks are shared by every tile
|
||||
and every browser, survive a restart, and expire on their own so last
|
||||
week's never greet a new Sunday. The + picker offers the whole board or a
|
||||
single campus.
|
||||
Admin config is in four groups: **Website** (address, which campuses to
|
||||
follow, refresh interval), **Broadcast** (the campuses the broadcast comes
|
||||
from, and each other campus's resi source), **Manual campuses** (a stopgap
|
||||
for a campus the site has not published yet — the website takes over as
|
||||
soon as it does) and **Marks** (how long a mark lives).
|
||||
Tile settings: campuses, card or list layout, and a **Show** group for the
|
||||
day, role, resi source, campuses fed, notes and the updated line.
|
||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||
reference for module authors.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Band Lineup module
|
||||
|
||||
The backstage stage board as a ProdDash tile: everyone scheduled on the band /
|
||||
vocal / production teams for the upcoming Planning Center plan, placed into their
|
||||
mic slot with photo, name and mic/pack label. People whose birthday falls in the
|
||||
current service week (Wednesday→Tuesday) get raining confetti and a gold glow.
|
||||
|
||||
This is the display half of the standalone **Band Lineup** Electron app, ported
|
||||
to ProdDash. (The ProPresenter Now/Next half of that app is the separate
|
||||
`propresenter-now-next` module.)
|
||||
|
||||
## How it fills the board
|
||||
|
||||
1. **Slots** are the mic/pack labels you write in admin, one per line, in the
|
||||
order they sit on stage — e.g. `[VOX 2] MIX 2`, `[DRUMS]`, `[MC] MC1`. A
|
||||
blank line is an empty spot.
|
||||
2. Each refresh the server reads the upcoming plan's **team members** (any team
|
||||
whose name matches a line in *Teams to include*), and matches each person to
|
||||
a slot by their Planning Center **position** — the same loose matching the
|
||||
Electron app uses (`matching.js`; `[VOX 2] MIX 2` takes whoever is on *Vox
|
||||
2*). A numbered role only matches the same number, and the `MIX n` part is
|
||||
ignored, so Vox 2 and Vox 3 never swap.
|
||||
3. **Photos and birthdays** come from Planning Center (birthdays via the People
|
||||
API; only month + day is kept). Photos are cached on disk and served by the
|
||||
module; birthdays drive the confetti.
|
||||
|
||||
## Manual photos (people not on the plan)
|
||||
|
||||
Guest speakers, MCs, stage managers — anyone not scheduled in Planning Center —
|
||||
are pinned per slot from the tile's **Edit** button (title bar): set a name,
|
||||
optionally upload a photo, optionally a `MM-DD` birthday. A manual pin overrides
|
||||
the PCO match for that slot and is kept until you Clear it. Uploaded photos live
|
||||
in the ProdDash data directory, never in the repo.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Admin** (server-wide): Planning Center credentials, service type, plan, the
|
||||
teams to include, the stage slots, columns, refresh interval.
|
||||
- **Per tile** (gear): birthday confetti on/off + intensity, a force-birthday
|
||||
test picker, parallax, and show/hide for names and mic labels.
|
||||
|
||||
## Testing without Planning Center
|
||||
|
||||
```bash
|
||||
node tools/pco-band-mock.js 24710 # mock PCO
|
||||
BAND_LINEUP_API_BASE=http://127.0.0.1:24710 node server.js # point the module at it
|
||||
node modules/band-lineup/matching.test.js # slot-matching unit tests
|
||||
```
|
||||
|
||||
Then in `/admin` under Band Lineup enter any Client ID + Secret, pick "Sunday
|
||||
Service", and the board fills — one person (on Drums) has a birthday this week,
|
||||
so their tile rains confetti.
|
||||
@@ -0,0 +1,471 @@
|
||||
/* Service Times — the board.
|
||||
*
|
||||
* One card per campus, one row per service, and a mark on any service that
|
||||
* production has to treat differently this week. Everything is drawn from the
|
||||
* server's SSE state so every booth sees the same marks at the same moment;
|
||||
* nothing here talks to the website.
|
||||
*
|
||||
* Marking lives in the tile body, not the title bar, because the bar folds
|
||||
* away in fullscreen and on an auto-hiding dashboard — a control an operator
|
||||
* needs mid-service may not disappear with it.
|
||||
*/
|
||||
|
||||
const STATUSES = [
|
||||
{ value: 'ok', label: 'Clear', short: '' },
|
||||
{ value: 'issue', label: 'Issue', short: 'Issue' },
|
||||
{ value: 'replay', label: 'Replay', short: 'Replay' },
|
||||
{ value: 'skip', label: 'Do not use', short: 'Do not use' },
|
||||
];
|
||||
|
||||
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
|
||||
+ '<path d="M13.6 1.9v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const ICON_CLEAR = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M3 4h10M6.5 4V2.8h3V4M4.4 4l.7 8.4a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9L11.6 4"'
|
||||
+ ' fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const ICON_SMALLER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
const ICON_BIGGER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||
+ '<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
|
||||
const TEXT_MIN = 0.8;
|
||||
const TEXT_MAX = 1.8;
|
||||
const TEXT_STEP = 0.1;
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let stream = null;
|
||||
let state = null;
|
||||
let editing = null; // { campus, service } while the editor is open
|
||||
let pendingRender = false; // a state arrived while the editor was open
|
||||
let board = null;
|
||||
let closeEditor = null;
|
||||
|
||||
const settings = () => moduleApi.instanceSettings;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The campuses this tile is asked to show; no choice made means all of them. */
|
||||
function visibleCampuses() {
|
||||
const all = state?.campuses || [];
|
||||
const chosen = settings().campuses;
|
||||
if (!Array.isArray(chosen) || !chosen.length) return all;
|
||||
return all.filter((campus) => chosen.includes(campus.slug));
|
||||
}
|
||||
|
||||
function textScale() {
|
||||
const saved = Number(settings().textSize);
|
||||
return Number.isFinite(saved) ? Math.min(TEXT_MAX, Math.max(TEXT_MIN, saved)) : 1;
|
||||
}
|
||||
|
||||
function serviceLabel(service) {
|
||||
return service.label ? `${service.label} ${service.text}` : service.text;
|
||||
}
|
||||
|
||||
/** Every service of the campus a resi campus takes its feed from. */
|
||||
function sourceServices(campus) {
|
||||
if (!campus.source) return [];
|
||||
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||
return origin ? origin.services : [];
|
||||
}
|
||||
|
||||
/** The mark on the origin service a resi service is taking, if any. */
|
||||
function upstreamTrouble(campus, service) {
|
||||
const sourceId = service.mark?.source;
|
||||
if (!sourceId || !campus.source) return null;
|
||||
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||
const upstream = origin?.services.find((s) => s.id === sourceId);
|
||||
if (!upstream?.mark || upstream.mark.status === 'ok') return null;
|
||||
return { origin, upstream };
|
||||
}
|
||||
|
||||
/** Resi services pointed at this broadcast service. */
|
||||
function dependentsOf(campus, service) {
|
||||
if (campus.role !== 'broadcast') return [];
|
||||
const out = [];
|
||||
for (const other of state?.campuses || []) {
|
||||
if (other.source !== campus.slug) continue;
|
||||
for (const theirs of other.services) {
|
||||
if (theirs.mark?.source === service.id) out.push({ campus: other, service: theirs });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── drawing ──────────────────────────────────────────────────────── */
|
||||
|
||||
function renderServiceRow(campus, service) {
|
||||
const opts = settings();
|
||||
const row = el('button', 'st-row');
|
||||
row.type = 'button';
|
||||
const status = service.mark?.status || 'ok';
|
||||
if (status !== 'ok') row.classList.add(`st-${status}`);
|
||||
|
||||
const time = el('span', 'st-time', serviceLabel(service));
|
||||
row.appendChild(time);
|
||||
|
||||
if (opts.showDay && service.day) row.appendChild(el('span', 'st-day', service.day));
|
||||
|
||||
const chips = el('span', 'st-chips');
|
||||
const statusDef = STATUSES.find((s) => s.value === status);
|
||||
if (statusDef?.short) chips.appendChild(el('span', `st-chip st-chip-${status}`, statusDef.short));
|
||||
|
||||
// A resi service says which of the origin's services it is taking…
|
||||
if (opts.showSource && campus.role === 'resi' && service.mark?.source) {
|
||||
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||
const upstream = origin?.services.find((s) => s.id === service.mark.source);
|
||||
if (upstream) chips.appendChild(el('span', 'st-chip st-chip-source', `← ${upstream.text}`));
|
||||
}
|
||||
// …and turns red when that service is the one in trouble.
|
||||
const trouble = upstreamTrouble(campus, service);
|
||||
if (trouble) {
|
||||
row.classList.add('st-affected');
|
||||
const what = STATUSES.find((s) => s.value === trouble.upstream.mark.status)?.short || 'marked';
|
||||
chips.appendChild(el('span', 'st-chip st-chip-skip', `source ${what.toLowerCase()}`));
|
||||
}
|
||||
|
||||
// A broadcast service says who is riding on it.
|
||||
if (opts.showDependents && campus.role === 'broadcast') {
|
||||
const riders = dependentsOf(campus, service);
|
||||
if (riders.length && status !== 'ok') {
|
||||
chips.appendChild(el('span', 'st-chip st-chip-warn',
|
||||
`affects ${riders.map((r) => r.campus.name).join(', ')}`));
|
||||
} else if (riders.length) {
|
||||
chips.appendChild(el('span', 'st-chip st-chip-quiet', `→ ${riders.length}`));
|
||||
}
|
||||
}
|
||||
row.appendChild(chips);
|
||||
|
||||
if (opts.showNotes && service.mark?.note) {
|
||||
row.appendChild(el('span', 'st-note', service.mark.note));
|
||||
}
|
||||
|
||||
row.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openEditor(campus, service, row);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderCampus(campus) {
|
||||
const opts = settings();
|
||||
const card = el('section', 'st-campus');
|
||||
if (campus.role === 'broadcast') card.classList.add('st-is-broadcast');
|
||||
|
||||
const head = el('header', 'st-campus-head');
|
||||
head.appendChild(el('h3', 'st-campus-name', campus.name));
|
||||
|
||||
if (opts.showRole) {
|
||||
if (campus.role === 'broadcast') {
|
||||
head.appendChild(el('span', 'st-role st-role-broadcast', 'Broadcast'));
|
||||
} else if (campus.source) {
|
||||
head.appendChild(el('span', 'st-role', campus.sourceMissing
|
||||
? `resi ← ${campus.sourceName} (not listed)`
|
||||
: `resi ← ${campus.sourceName}`));
|
||||
} else {
|
||||
head.appendChild(el('span', 'st-role st-role-unset', 'no resi source'));
|
||||
}
|
||||
}
|
||||
if (campus.manual) head.appendChild(el('span', 'st-role st-role-manual', 'manual'));
|
||||
card.appendChild(head);
|
||||
|
||||
if (opts.showDependents && campus.role === 'broadcast' && campus.dependents?.length) {
|
||||
card.appendChild(el('p', 'st-feeds', `feeds ${campus.dependents.map((d) => d.name).join(', ')}`));
|
||||
}
|
||||
|
||||
if (!campus.services.length) {
|
||||
card.appendChild(el('p', 'st-empty', campus.error || 'No times published'));
|
||||
return card;
|
||||
}
|
||||
const list = el('div', 'st-rows');
|
||||
for (const service of campus.services) list.appendChild(renderServiceRow(campus, service));
|
||||
card.appendChild(list);
|
||||
return card;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (closeEditor) closeEditor();
|
||||
root.innerHTML = '';
|
||||
board = el('div', 'st-board');
|
||||
board.style.fontSize = `${textScale()}em`;
|
||||
root.appendChild(board);
|
||||
|
||||
if (!state) {
|
||||
board.appendChild(el('p', 'st-empty', 'Waiting for the server…'));
|
||||
return;
|
||||
}
|
||||
if (!state.configured) {
|
||||
board.appendChild(el('p', 'st-empty', 'No website address configured — set one in Admin.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const campuses = visibleCampuses();
|
||||
if (!campuses.length) {
|
||||
board.appendChild(el('p', 'st-empty', state.reachable
|
||||
? 'No campuses to show. Pick some in Admin, or in this tile’s settings.'
|
||||
: 'No service times yet.'));
|
||||
} else {
|
||||
const grid = el('div', settings().layout === 'list' ? 'st-grid st-grid-list' : 'st-grid');
|
||||
for (const campus of campuses) grid.appendChild(renderCampus(campus));
|
||||
board.appendChild(grid);
|
||||
}
|
||||
|
||||
if (!state.reachable && state.lastError) {
|
||||
board.appendChild(el('p', 'st-warn', `Website unreachable — ${state.lastError}. Showing the last times read.`));
|
||||
}
|
||||
if (settings().showUpdated && state.lastFetched) {
|
||||
board.appendChild(el('p', 'st-updated',
|
||||
`Times read ${new Date(state.lastFetched).toLocaleString([], { hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })}`));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the mark editor ──────────────────────────────────────────────── */
|
||||
|
||||
function openEditor(campus, service, anchor) {
|
||||
if (closeEditor) closeEditor();
|
||||
editing = { campus: campus.slug, service: service.id };
|
||||
|
||||
const panel = el('div', 'st-editor');
|
||||
panel.appendChild(el('div', 'st-editor-title', `${campus.name} — ${serviceLabel(service)}`));
|
||||
|
||||
const mark = service.mark || { status: 'ok', note: '', source: '' };
|
||||
let status = mark.status || 'ok';
|
||||
|
||||
const row = el('div', 'st-editor-status');
|
||||
const buttons = new Map();
|
||||
for (const def of STATUSES) {
|
||||
const button = el('button', 'st-pick', def.label);
|
||||
button.type = 'button';
|
||||
if (def.value === status) button.classList.add('active');
|
||||
button.addEventListener('click', () => {
|
||||
status = def.value;
|
||||
for (const [value, node] of buttons) node.classList.toggle('active', value === status);
|
||||
});
|
||||
buttons.set(def.value, button);
|
||||
row.appendChild(button);
|
||||
}
|
||||
panel.appendChild(row);
|
||||
|
||||
// Which of the origin's services this one is taking — the answer to
|
||||
// "which service, and where, can be altered".
|
||||
let sourceSelect = null;
|
||||
const origins = campus.role === 'resi' ? sourceServices(campus) : [];
|
||||
if (origins.length) {
|
||||
const wrap = el('label', 'st-editor-field');
|
||||
wrap.appendChild(el('span', 'st-editor-label', `Taking from ${campus.sourceName}`));
|
||||
sourceSelect = el('select', 'st-editor-input');
|
||||
const none = el('option', null, '— not set —');
|
||||
none.value = '';
|
||||
sourceSelect.appendChild(none);
|
||||
for (const candidate of origins) {
|
||||
const option = el('option', null, serviceLabel(candidate));
|
||||
option.value = candidate.id;
|
||||
if (candidate.id === mark.source) option.selected = true;
|
||||
sourceSelect.appendChild(option);
|
||||
}
|
||||
wrap.appendChild(sourceSelect);
|
||||
panel.appendChild(wrap);
|
||||
}
|
||||
|
||||
const noteWrap = el('label', 'st-editor-field');
|
||||
noteWrap.appendChild(el('span', 'st-editor-label', 'Note'));
|
||||
const note = el('input', 'st-editor-input');
|
||||
note.type = 'text';
|
||||
note.maxLength = 280;
|
||||
note.value = mark.note || '';
|
||||
note.placeholder = 'What production needs to know';
|
||||
noteWrap.appendChild(note);
|
||||
panel.appendChild(noteWrap);
|
||||
|
||||
const actions = el('div', 'st-editor-actions');
|
||||
const save = el('button', 'st-pick st-save', 'Save');
|
||||
save.type = 'button';
|
||||
const cancel = el('button', 'st-pick', 'Cancel');
|
||||
cancel.type = 'button';
|
||||
actions.append(save, cancel);
|
||||
panel.appendChild(actions);
|
||||
|
||||
async function commit() {
|
||||
const body = {
|
||||
campus: campus.slug,
|
||||
service: service.id,
|
||||
status,
|
||||
note: note.value.trim(),
|
||||
source: sourceSelect ? sourceSelect.value : (mark.source || ''),
|
||||
};
|
||||
close();
|
||||
try {
|
||||
const response = await moduleApi.fetch('/mark', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status}`);
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Could not save the mark: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
save.addEventListener('click', commit);
|
||||
cancel.addEventListener('click', () => close());
|
||||
note.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') commit();
|
||||
if (event.key === 'Escape') close();
|
||||
});
|
||||
panel.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
function onOutside() { close(); }
|
||||
function onKey(event) { if (event.key === 'Escape') close(); }
|
||||
|
||||
function close() {
|
||||
if (closeEditor !== close) return;
|
||||
panel.remove();
|
||||
anchor?.classList.remove('st-open');
|
||||
root.removeEventListener('click', onOutside);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
closeEditor = null;
|
||||
editing = null;
|
||||
if (pendingRender) {
|
||||
pendingRender = false;
|
||||
render(); // closeEditor is already null, so this cannot re-enter
|
||||
}
|
||||
}
|
||||
|
||||
closeEditor = close;
|
||||
anchor?.classList.add('st-open');
|
||||
board.appendChild(panel);
|
||||
root.addEventListener('click', onOutside);
|
||||
document.addEventListener('keydown', onKey);
|
||||
note.focus();
|
||||
note.select();
|
||||
}
|
||||
|
||||
/* ── lifecycle ────────────────────────────────────────────────────── */
|
||||
|
||||
function applyState(next) {
|
||||
state = next;
|
||||
if (!state) {
|
||||
moduleApi.setStatus('connecting', 'Waiting for the server');
|
||||
} else if (!state.configured) {
|
||||
moduleApi.setStatus('error', 'No website address configured');
|
||||
} else if (!state.reachable) {
|
||||
moduleApi.setStatus('error', state.lastError
|
||||
? `Website unreachable — ${state.lastError}`
|
||||
: 'Website unreachable');
|
||||
} else if (state.lastError) {
|
||||
moduleApi.setStatus('connecting', state.lastError);
|
||||
} else {
|
||||
const count = (state.campuses || []).reduce((sum, c) => sum + c.services.length, 0);
|
||||
moduleApi.setStatus('ok', `${count} service(s) across ${(state.campuses || []).length} campus(es)`);
|
||||
}
|
||||
// Re-rendering would close an open editor mid-sentence, so hold the change
|
||||
// back while someone is typing — and draw it the moment they are done, or
|
||||
// the board would sit stale until the next change happened to arrive.
|
||||
if (editing) {
|
||||
pendingRender = true;
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const response = await moduleApi.fetch('/state');
|
||||
const body = await response.json();
|
||||
applyState(body.state);
|
||||
} catch {
|
||||
/* the stream will bring it */
|
||||
}
|
||||
}
|
||||
|
||||
function setTextSize(delta) {
|
||||
const next = Math.min(TEXT_MAX, Math.max(TEXT_MIN, textScale() + delta));
|
||||
moduleApi.saveInstanceSettings({ textSize: Number(next.toFixed(2)) });
|
||||
if (board) board.style.fontSize = `${next}em`;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Reading service times');
|
||||
render();
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_REFRESH,
|
||||
title: 'Refresh times',
|
||||
onClick: async () => {
|
||||
moduleApi.setStatus('connecting', 'Re-reading the website');
|
||||
try {
|
||||
const response = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||
const body = await response.json();
|
||||
if (body.state) applyState(body.state);
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Refresh failed: ${err.message || err}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_CLEAR,
|
||||
title: 'Clear marks',
|
||||
onClick: async () => {
|
||||
const marked = (state?.campuses || []).reduce(
|
||||
(sum, campus) => sum + campus.services.filter((s) => s.mark).length, 0
|
||||
);
|
||||
if (!marked) return;
|
||||
// Marks are shared by every campus watching — never wipe them silently.
|
||||
if (!window.confirm(`Clear ${marked} mark${marked === 1 ? '' : 's'} for every campus?`)) return;
|
||||
try {
|
||||
await moduleApi.fetch('/clear', { method: 'POST' });
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Could not clear marks: ${err.message || err}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_SMALLER,
|
||||
title: 'Smaller text',
|
||||
onClick: () => setTextSize(-TEXT_STEP),
|
||||
});
|
||||
moduleApi.header.addButton({
|
||||
icon: ICON_BIGGER,
|
||||
title: 'Larger text',
|
||||
onClick: () => setTextSize(TEXT_STEP),
|
||||
});
|
||||
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() {
|
||||
// The shared connection was (re)made — we may have missed a change.
|
||||
loadState();
|
||||
},
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||
},
|
||||
events: {
|
||||
state(event) {
|
||||
try {
|
||||
applyState(JSON.parse(event.data));
|
||||
} catch { /* a truncated frame; the next one will be whole */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
loadState();
|
||||
},
|
||||
|
||||
stop() {
|
||||
pendingRender = false; // nothing to catch up on: the tile is going away
|
||||
if (closeEditor) closeEditor();
|
||||
stream?.close();
|
||||
stream = null;
|
||||
state = null;
|
||||
editing = null;
|
||||
board = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"id": "service-times",
|
||||
"name": "Service Times",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Sunday service times for every campus, marked up for replays and services production cannot use",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 6, "h": 5 },
|
||||
"configGroups": {
|
||||
"Website": {
|
||||
"help": "Service times are read from the campus pages at <site>/locations/<campus>/ and refreshed on their own, so the board follows the website whenever the times change. Campuses are discovered from the site's sitemap — tick the ones production is responsible for."
|
||||
},
|
||||
"Broadcast": {
|
||||
"help": "Which campuses originate the Sunday broadcast, and which campus each of the others takes its resi from. This is what lets the board show, when a service is marked unusable, every campus affected by it."
|
||||
},
|
||||
"Manual campuses": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "A stopgap for a campus the website has not published yet. The website stays the source of truth: as soon as it carries a campus by this name, its times take over and the health line above says the line here can be removed."
|
||||
},
|
||||
"Marks": {
|
||||
"collapsed": true,
|
||||
"help": "Marks are shared by every tile and every browser, and survive a restart of ProdDash."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"siteUrl": {
|
||||
"type": "string",
|
||||
"label": "Website",
|
||||
"default": "https://waterschurch.org",
|
||||
"group": "Website",
|
||||
"help": "The church website's address. Campus pages are read from /locations/ underneath it."
|
||||
},
|
||||
"campuses": {
|
||||
"type": "multiselect",
|
||||
"label": "Campuses",
|
||||
"default": ["north-attleboro", "woonsocket", "southcoast", "apollo-beach"],
|
||||
"group": "Website",
|
||||
"optionsRoute": "/discovered",
|
||||
"help": "Leave every campus unticked to follow all of them."
|
||||
},
|
||||
"refreshMinutes": {
|
||||
"type": "number",
|
||||
"label": "Refresh (min)",
|
||||
"default": 360,
|
||||
"group": "Website",
|
||||
"help": "How often the website is re-read. Times change a few times a year, so this can be slow; a failed read retries every minute and the last good times stay on screen."
|
||||
},
|
||||
"broadcast": {
|
||||
"type": "multiselect",
|
||||
"label": "Broadcast campuses",
|
||||
"default": ["north-attleboro", "apollo-beach"],
|
||||
"group": "Broadcast",
|
||||
"optionsRoute": "/campuses",
|
||||
"help": "The campuses the broadcast originates from."
|
||||
},
|
||||
"feeds": {
|
||||
"type": "text",
|
||||
"label": "Resi sources",
|
||||
"default": "woonsocket = north-attleboro\nsouthcoast = north-attleboro",
|
||||
"group": "Broadcast",
|
||||
"help": "One campus per line: campus = the broadcast campus it takes resi from. Names or slugs both work."
|
||||
},
|
||||
"manual": {
|
||||
"type": "text",
|
||||
"label": "Campuses and times",
|
||||
"default": "",
|
||||
"group": "Manual campuses",
|
||||
"help": "One campus per line: Name | Day | 9:00a, 11:00a. The day is optional and defaults to Sunday. Lines starting with # are ignored."
|
||||
},
|
||||
"retentionHours": {
|
||||
"type": "number",
|
||||
"label": "Marks clear after (h)",
|
||||
"default": 36,
|
||||
"group": "Marks",
|
||||
"help": "A mark disappears this long after it was made, so Saturday's and Sunday's notes are gone before the next week. Set 0 to keep marks until they are cleared by hand."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Show": { "collapsed": true, "columns": 2 }
|
||||
},
|
||||
"instanceSchema": {
|
||||
"campuses": {
|
||||
"type": "multiselect",
|
||||
"label": "Campuses",
|
||||
"optionsRoute": "/campuses",
|
||||
"columns": 2,
|
||||
"allByDefault": true
|
||||
},
|
||||
"layout": {
|
||||
"type": "select",
|
||||
"label": "Layout",
|
||||
"default": "cards",
|
||||
"options": [
|
||||
{ "value": "cards", "label": "Cards" },
|
||||
{ "value": "list", "label": "List" }
|
||||
]
|
||||
},
|
||||
"showDay": { "type": "boolean", "label": "Day", "default": true, "group": "Show" },
|
||||
"showRole": { "type": "boolean", "label": "Role", "default": true, "group": "Show" },
|
||||
"showSource": { "type": "boolean", "label": "Resi source", "default": true, "group": "Show" },
|
||||
"showDependents": { "type": "boolean", "label": "Campuses fed", "default": true, "group": "Show" },
|
||||
"showNotes": { "type": "boolean", "label": "Notes", "default": true, "group": "Show" },
|
||||
"showUpdated": { "type": "boolean", "label": "Updated line", "default": false, "group": "Show" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Service Times — reading the church website.
|
||||
*
|
||||
* Pure functions, no I/O, so every shape below can be checked without the
|
||||
* network (parse.test.js). The server entry does the fetching.
|
||||
*
|
||||
* Every campus page is built from the same page-builder blocks: a "Times"
|
||||
* heading, the day in bold, then the service times under it, ending before
|
||||
* the "Get Involved" heading. Bounding the slice that way is what keeps the
|
||||
* footer's main-office hours out of the service list — and some campuses
|
||||
* (Apollo Beach) carry their own "Office Hours" block inside the Times
|
||||
* section, which is why STOP_HEADINGS ends the walk early as well.
|
||||
*
|
||||
* The four shapes seen in the wild, all handled by walkSection():
|
||||
*
|
||||
* Woonsocket / Apollo Beach Sunday → 9:00a, 11:00a
|
||||
* 9:00a & 11:00a
|
||||
* North Attleboro Sunday → 7:30a, 9:30a, 11:30a
|
||||
* 7:30a<br>9:30a<br>11:30a
|
||||
* Orlando Sundays → Welcome 10:00a,
|
||||
* Welcome 10:00am Start 10:30a
|
||||
* Start 10:30am
|
||||
*/
|
||||
|
||||
const WEEKDAYS = [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||
];
|
||||
|
||||
/** Headings that end the service list wherever they turn up in the section. */
|
||||
const STOP_HEADINGS = /^(office\s*hours?|get\s*involved|location\s*pastor|contact|address|events?)\b/i;
|
||||
|
||||
/** A time: "9:00a", "11:30am", "7a", "10:00 a.m." — never "1 person". */
|
||||
const TIME_RE = /(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*(?:m\.?)?(?![a-z])/gi;
|
||||
|
||||
/** "9:00a-4:30p" — an hours range, not two services. */
|
||||
const RANGE_RE = /\d\s*(?::\d{2})?\s*[ap]\.?m?\.?\s*[-–—]\s*\d/i;
|
||||
|
||||
const NAMED_ENTITIES = {
|
||||
amp: '&', nbsp: ' ', quot: '"', apos: "'", lt: '<', gt: '>',
|
||||
ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
|
||||
rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
|
||||
};
|
||||
|
||||
function fromCodePoint(n) {
|
||||
try {
|
||||
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEntities(input) {
|
||||
return String(input == null ? '' : input)
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => fromCodePoint(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, dec) => fromCodePoint(parseInt(dec, 10)))
|
||||
.replace(/&([a-z]+);/gi, (whole, name) => {
|
||||
const value = NAMED_ENTITIES[name.toLowerCase()];
|
||||
return value === undefined ? whole : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML → the lines a reader would see. Block ends and <br> become line
|
||||
* breaks so "7:30a<br>9:30a" stays two services rather than one run-on.
|
||||
*/
|
||||
function htmlToLines(html) {
|
||||
return String(html == null ? '' : html)
|
||||
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|ul|ol|h[1-6]|td|tr|span|strong|b|em|i|a)\s*>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => decodeEntities(line).replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Title Case a slug: "north-attleboro" → "North Attleboro". */
|
||||
function nameFromSlug(slug) {
|
||||
return String(slug || '')
|
||||
.split(/[-_/]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return String(name == null ? '' : name)
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/** A weekday heading — "Sunday", "Sundays", "SUNDAY" — and nothing else. */
|
||||
function dayFromLine(line) {
|
||||
const cleaned = String(line || '').toLowerCase().replace(/[^a-z]+/g, '');
|
||||
if (!cleaned) return '';
|
||||
for (const day of WEEKDAYS) {
|
||||
if (cleaned === day || cleaned === `${day}s`) {
|
||||
return day.charAt(0).toUpperCase() + day.slice(1);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** "9:00a" → { hour24: 9, minute: 0, text: "9:00a" }; 12a/12p handled. */
|
||||
function toService(hourRaw, minuteRaw, meridiemRaw) {
|
||||
const hour12 = Number(hourRaw);
|
||||
const minute = Number(minuteRaw || 0);
|
||||
const pm = String(meridiemRaw || '').toLowerCase() === 'p';
|
||||
if (!Number.isFinite(hour12) || hour12 < 1 || hour12 > 12) return null;
|
||||
if (!Number.isFinite(minute) || minute > 59) return null;
|
||||
const hour24 = pm ? (hour12 === 12 ? 12 : hour12 + 12) : hour12 === 12 ? 0 : hour12;
|
||||
return {
|
||||
hour24,
|
||||
minute,
|
||||
minutes: hour24 * 60 + minute,
|
||||
text: `${hour12}:${pad2(minute)}${pm ? 'p' : 'a'}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Times on one line, with the words in front of the first one as a label
|
||||
* ("Welcome 10:00am" → label "Welcome"). A line holding an hours range
|
||||
* ("9:00a-4:30p") is office hours, not services, and yields nothing.
|
||||
*/
|
||||
function timesFromLine(line) {
|
||||
const text = String(line || '');
|
||||
if (RANGE_RE.test(text)) return [];
|
||||
TIME_RE.lastIndex = 0;
|
||||
const found = [];
|
||||
let first = -1;
|
||||
let match = TIME_RE.exec(text);
|
||||
while (match) {
|
||||
const service = toService(match[1], match[2], match[3]);
|
||||
if (service) {
|
||||
if (first < 0) first = match.index;
|
||||
found.push(service);
|
||||
}
|
||||
match = TIME_RE.exec(text);
|
||||
}
|
||||
if (!found.length) return [];
|
||||
const label = first > 0
|
||||
? text.slice(0, first).replace(/[\s|,;:&/–—-]+$/, '').trim()
|
||||
: '';
|
||||
// A label belongs to a single time ("Start 10:30am"); a list of times on
|
||||
// one line ("9:00a & 11:00a") shares none.
|
||||
return found.map((service) => ({ ...service, label: found.length === 1 ? label : '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the lines of a Times section into services, stopping at the first
|
||||
* stop-heading. Lines before any weekday heading still count — a campus
|
||||
* that lists bare times under "Times" is read as Sunday.
|
||||
*/
|
||||
function walkSection(lines, { defaultDay = 'Sunday' } = {}) {
|
||||
const services = [];
|
||||
let day = '';
|
||||
for (const line of lines) {
|
||||
if (STOP_HEADINGS.test(line)) break;
|
||||
const heading = dayFromLine(line);
|
||||
if (heading) {
|
||||
day = heading;
|
||||
continue;
|
||||
}
|
||||
for (const service of timesFromLine(line)) {
|
||||
services.push({ ...service, day: day || defaultDay });
|
||||
}
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
/** Give every service a stable id — marks are keyed on it, so it must not drift. */
|
||||
function withIds(services) {
|
||||
const seen = new Map();
|
||||
return services.map((service) => {
|
||||
const base = [
|
||||
pad2(service.hour24) + pad2(service.minute),
|
||||
slugify(service.label),
|
||||
].filter(Boolean).join('-');
|
||||
const count = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, count);
|
||||
return { ...service, id: count === 1 ? base : `${base}-${count}` };
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop exact duplicates (same day + time + label) while keeping order. */
|
||||
function dedupe(services) {
|
||||
const seen = new Set();
|
||||
return services.filter((service) => {
|
||||
const key = `${service.day}|${service.minutes}|${service.label.toLowerCase()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a campus page between the "Times" heading and whatever ends
|
||||
* it. Returns '' when the page has no Times heading, which the caller
|
||||
* reports rather than guessing at.
|
||||
*/
|
||||
function timesSection(html) {
|
||||
const text = String(html == null ? '' : html);
|
||||
const start = /<([a-z0-9]+)\b[^>]*>\s*times\s*<\/\1\s*>/i.exec(text)
|
||||
|| />\s*times\s*</i.exec(text);
|
||||
if (!start) return '';
|
||||
const from = start.index + start[0].length;
|
||||
const rest = text.slice(from);
|
||||
const end = /<([a-z0-9]+)\b[^>]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest)
|
||||
|| />\s*get\s+involved\s*</i.exec(rest);
|
||||
return end ? rest.slice(0, end.index) : rest.slice(0, 20000);
|
||||
}
|
||||
|
||||
/**
|
||||
* One campus page → { slug, name, day, services, error }. `error` is set
|
||||
* (and services empty) when the page no longer looks the way this parser
|
||||
* expects, so the admin health line can say the site changed rather than
|
||||
* quietly showing a campus with no services.
|
||||
*/
|
||||
function parseCampusPage(html, { slug = '', name = '' } = {}) {
|
||||
const campusSlug = slugify(slug);
|
||||
const campusName = name || nameFromSlug(campusSlug);
|
||||
const section = timesSection(html);
|
||||
if (!section) {
|
||||
return { slug: campusSlug, name: campusName, services: [], error: 'No "Times" section on the page' };
|
||||
}
|
||||
const services = withIds(dedupe(walkSection(htmlToLines(section))));
|
||||
if (!services.length) {
|
||||
return { slug: campusSlug, name: campusName, services: [], error: 'No service times under "Times"' };
|
||||
}
|
||||
return { slug: campusSlug, name: campusName, services, error: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Campus page slugs from a sitemap and/or any page's links. The sitemap is
|
||||
* the fuller list — it carries campuses the navigation does not link yet.
|
||||
*/
|
||||
function discoverCampusSlugs(...sources) {
|
||||
const slugs = new Set();
|
||||
for (const source of sources) {
|
||||
const text = String(source == null ? '' : source);
|
||||
const re = /\/locations\/([a-z0-9][a-z0-9-]*)\/?/gi;
|
||||
let match = re.exec(text);
|
||||
while (match) {
|
||||
const slug = slugify(match[1]);
|
||||
if (slug && slug !== 'locations') slugs.add(slug);
|
||||
match = re.exec(text);
|
||||
}
|
||||
}
|
||||
return [...slugs].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin "Manual campuses" text: one campus per line,
|
||||
*
|
||||
* Name | Day | 9:00a, 11:00a
|
||||
*
|
||||
* Day is optional and defaults to Sunday: "Plymouth | 9:00a & 11:00a".
|
||||
* A line the parser cannot read is returned in `errors` so the admin page
|
||||
* can say which line, rather than the campus silently not appearing.
|
||||
*/
|
||||
function parseManualCampuses(text) {
|
||||
const campuses = [];
|
||||
const errors = [];
|
||||
const lines = String(text == null ? '' : text).split(/\r?\n/);
|
||||
lines.forEach((raw, index) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const parts = line.split('|').map((part) => part.trim());
|
||||
const name = parts.shift() || '';
|
||||
if (!name) return;
|
||||
if (!parts.length) {
|
||||
errors.push(`Line ${index + 1}: "${line}" has no times`);
|
||||
return;
|
||||
}
|
||||
let day = '';
|
||||
if (parts.length > 1) {
|
||||
day = dayFromLine(parts[0]) || '';
|
||||
if (day) parts.shift();
|
||||
}
|
||||
const services = withIds(dedupe(
|
||||
walkSection(parts.join('\n').split('\n'), { defaultDay: day || 'Sunday' })
|
||||
));
|
||||
if (!services.length) {
|
||||
errors.push(`Line ${index + 1}: no times found in "${line}"`);
|
||||
return;
|
||||
}
|
||||
campuses.push({ slug: slugify(name), name, services, manual: true, error: '' });
|
||||
});
|
||||
return { campuses, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Website campuses and manual ones into the one list the tiles draw.
|
||||
*
|
||||
* The website wins: a manual campus is a stopgap for a site that has not
|
||||
* caught up, so as soon as the site publishes that campus (same slug) its
|
||||
* times take over and the manual entry is marked `supersededBy` — the admin
|
||||
* page says so, and the operator can delete the line.
|
||||
*/
|
||||
function mergeCampuses(siteCampuses, manualCampuses) {
|
||||
const merged = [];
|
||||
const bySlug = new Map();
|
||||
for (const campus of siteCampuses || []) {
|
||||
if (!campus || !campus.slug) continue;
|
||||
const entry = { ...campus, manual: false, superseded: false };
|
||||
bySlug.set(campus.slug, entry);
|
||||
merged.push(entry);
|
||||
}
|
||||
for (const campus of manualCampuses || []) {
|
||||
if (!campus || !campus.slug) continue;
|
||||
const fromSite = bySlug.get(campus.slug);
|
||||
if (fromSite) {
|
||||
// The site has it now — its times are the ones that matter.
|
||||
if (fromSite.services.length) {
|
||||
fromSite.supersededManual = true;
|
||||
continue;
|
||||
}
|
||||
// The page exists but gave us nothing usable; the manual line still helps.
|
||||
fromSite.services = campus.services;
|
||||
fromSite.manual = true;
|
||||
fromSite.error = '';
|
||||
continue;
|
||||
}
|
||||
const entry = { ...campus, superseded: false };
|
||||
bySlug.set(campus.slug, entry);
|
||||
merged.push(entry);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin "Resi sources" text: one campus per line,
|
||||
*
|
||||
* woonsocket = north-attleboro
|
||||
*
|
||||
* Names work as well as slugs — both sides are slugified, so an operator can
|
||||
* write what they say out loud. "->", ":" and "→" read as "=".
|
||||
*/
|
||||
function parseFeeds(text) {
|
||||
const feeds = new Map();
|
||||
const errors = [];
|
||||
String(text == null ? '' : text).split(/\r?\n/).forEach((raw, index) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const match = /^(.+?)\s*(?:=|->|:|→)\s*(.+)$/.exec(line);
|
||||
if (!match) {
|
||||
errors.push(`Line ${index + 1}: "${line}" is not "campus = broadcast campus"`);
|
||||
return;
|
||||
}
|
||||
const from = slugify(match[1]);
|
||||
const to = slugify(match[2]);
|
||||
if (!from || !to) {
|
||||
errors.push(`Line ${index + 1}: "${line}" is missing a campus`);
|
||||
return;
|
||||
}
|
||||
feeds.set(from, to);
|
||||
});
|
||||
return { feeds, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every campus its production role: which ones originate the broadcast,
|
||||
* which one each of the others takes its resi from, and — the question the
|
||||
* board exists to answer — who is riding on each origin.
|
||||
*
|
||||
* A resi campus pointed at a campus that is not on the board keeps the
|
||||
* pointer and is flagged (`sourceMissing`) rather than quietly reading as
|
||||
* unassigned: a typo in the admin text has to be visible, not invisible.
|
||||
*/
|
||||
function assignRoles(campuses, { broadcast = [], feeds = new Map() } = {}) {
|
||||
const origins = new Set([...broadcast].map((slug) => slugify(slug)).filter(Boolean));
|
||||
const shaped = (campuses || []).map((campus) => {
|
||||
const role = origins.has(campus.slug) ? 'broadcast' : 'resi';
|
||||
const source = role === 'broadcast' ? '' : feeds.get(campus.slug) || '';
|
||||
return { ...campus, role, source, sourceName: '', sourceMissing: false, dependents: [] };
|
||||
});
|
||||
const bySlug = new Map(shaped.map((campus) => [campus.slug, campus]));
|
||||
for (const campus of shaped) {
|
||||
if (!campus.source) continue;
|
||||
const origin = bySlug.get(campus.source);
|
||||
campus.sourceName = origin ? origin.name : campus.source;
|
||||
campus.sourceMissing = !origin;
|
||||
if (origin) origin.dependents.push({ slug: campus.slug, name: campus.name });
|
||||
}
|
||||
return shaped;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEEKDAYS,
|
||||
decodeEntities,
|
||||
htmlToLines,
|
||||
nameFromSlug,
|
||||
slugify,
|
||||
dayFromLine,
|
||||
timesFromLine,
|
||||
walkSection,
|
||||
timesSection,
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the Service Times parser.
|
||||
*
|
||||
* node modules/service-times/parse.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. The
|
||||
* fixtures are trimmed from the real campus pages, so each one stands for a
|
||||
* shape the website actually publishes — including Apollo Beach's "Office
|
||||
* Hours" block, which sits inside the Times section and must never be read
|
||||
* as a service.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
htmlToLines,
|
||||
slugify,
|
||||
dayFromLine,
|
||||
timesFromLine,
|
||||
timesSection,
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
} = require('./parse');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${name}\n ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** The page-builder wrapper every campus's Times block sits in. */
|
||||
const page = (inner) => `
|
||||
<div class="wpb_wrapper"><h5 class="q_title">Address</h5><p>1 Example Road</p></div>
|
||||
<div class="wpb_wrapper"><h5 class="q_title">Times</h5>
|
||||
${inner}
|
||||
</div>
|
||||
<div class="wpb_wrapper"><h5 class="q_title">Get Involved</h5><p>Give now</p></div>
|
||||
<div class="footer"><p>Mon – Thu 9a – 4:30p</p></div>
|
||||
`;
|
||||
|
||||
const WOONSOCKET = page('<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>');
|
||||
const NORTH_ATTLEBORO = page('<p><strong>Sunday</strong></p><p>7:30a<br />9:30a<br />11:30a</p>');
|
||||
const ORLANDO = page('<p><b>Sundays</b></p><p>Welcome 10:00am</p><p>Start 10:30am</p>');
|
||||
const SOUTHCOAST = page('<p><strong>Sunday</strong></p><p>10:30a</p>');
|
||||
const APOLLO_BEACH = page(
|
||||
'<p><strong style="color:#333">Sunday</strong></p>'
|
||||
+ '<p><span style="color:#333">9:00a & 11:00a</span></p>'
|
||||
+ '<h5 class="q_title">Office Hours</h5><p>Mon-Wed 9:00a-4:30p</p>'
|
||||
);
|
||||
|
||||
const times = (html, slug) => parseCampusPage(html, { slug }).services.map((s) => s.text);
|
||||
|
||||
console.log('campus pages');
|
||||
|
||||
test('Woonsocket: two times joined by "&" on one line', () => {
|
||||
assert.deepStrictEqual(times(WOONSOCKET, 'woonsocket'), ['9:00a', '11:00a']);
|
||||
});
|
||||
|
||||
test('North Attleboro: three times separated by <br>', () => {
|
||||
assert.deepStrictEqual(times(NORTH_ATTLEBORO, 'north-attleboro'), ['7:30a', '9:30a', '11:30a']);
|
||||
});
|
||||
|
||||
test('Southcoast: a single service', () => {
|
||||
assert.deepStrictEqual(times(SOUTHCOAST, 'southcoast'), ['10:30a']);
|
||||
});
|
||||
|
||||
test('Orlando: "am" spelling, one labelled time per line', () => {
|
||||
const services = parseCampusPage(ORLANDO, { slug: 'orlando' }).services;
|
||||
assert.deepStrictEqual(services.map((s) => s.text), ['10:00a', '10:30a']);
|
||||
assert.deepStrictEqual(services.map((s) => s.label), ['Welcome', 'Start']);
|
||||
});
|
||||
|
||||
test('Apollo Beach: the Office Hours block inside Times is not a service', () => {
|
||||
assert.deepStrictEqual(times(APOLLO_BEACH, 'apollo-beach'), ['9:00a', '11:00a']);
|
||||
});
|
||||
|
||||
/* Apollo Beach's real page lists an hours *range* under Office Hours, which the
|
||||
range guard rejects on its own — so the test above passes even if the
|
||||
stop-heading breaks. This fixture puts plain times there instead, where only
|
||||
the stop-heading can keep them out: without it, the 2:00p becomes a service. */
|
||||
const OFFICE_HOURS_PLAIN = page(
|
||||
'<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>'
|
||||
+ '<h5 class="q_title">Office Hours</h5><p>Mon 9:00a</p><p>Thu 2:00p</p>'
|
||||
);
|
||||
|
||||
test('an Office Hours block ends the service list, range or not', () => {
|
||||
assert.deepStrictEqual(times(OFFICE_HOURS_PLAIN, 'apollo-beach'), ['9:00a', '11:00a']);
|
||||
});
|
||||
|
||||
|
||||
test('the footer\'s main-office hours never reach the services', () => {
|
||||
for (const [html, slug] of [[WOONSOCKET, 'woonsocket'], [SOUTHCOAST, 'southcoast']]) {
|
||||
assert.ok(!times(html, slug).includes('4:30p'), `${slug} picked up the footer`);
|
||||
}
|
||||
});
|
||||
|
||||
test('names and days come out as the site writes them', () => {
|
||||
const campus = parseCampusPage(NORTH_ATTLEBORO, { slug: 'north-attleboro' });
|
||||
assert.strictEqual(campus.name, 'North Attleboro');
|
||||
assert.deepStrictEqual([...new Set(campus.services.map((s) => s.day))], ['Sunday']);
|
||||
});
|
||||
|
||||
test('ids are stable and unique — marks are keyed on them', () => {
|
||||
const first = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||||
const again = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||||
assert.deepStrictEqual(first, ['1000-welcome', '1030-start']);
|
||||
assert.deepStrictEqual(first, again);
|
||||
assert.strictEqual(new Set(first).size, first.length);
|
||||
});
|
||||
|
||||
test('a page that no longer has a Times section reports it', () => {
|
||||
const campus = parseCampusPage('<h1>Redirected elsewhere</h1>', { slug: 'norwood' });
|
||||
assert.deepStrictEqual(campus.services, []);
|
||||
assert.match(campus.error, /Times/);
|
||||
});
|
||||
|
||||
console.log('pieces');
|
||||
|
||||
test('24-hour values and sort order', () => {
|
||||
const [nine] = timesFromLine('9:00a');
|
||||
const [noon] = timesFromLine('12:00p');
|
||||
const [midnight] = timesFromLine('12:00a');
|
||||
const [evening] = timesFromLine('6:30p');
|
||||
assert.strictEqual(nine.hour24, 9);
|
||||
assert.strictEqual(noon.hour24, 12);
|
||||
assert.strictEqual(midnight.hour24, 0);
|
||||
assert.strictEqual(evening.minutes, 18 * 60 + 30);
|
||||
});
|
||||
|
||||
test('an hours range is not a service list', () => {
|
||||
assert.deepStrictEqual(timesFromLine('Mon-Wed 9:00a-4:30p'), []);
|
||||
assert.deepStrictEqual(timesFromLine('9a – 4:30p'), []);
|
||||
});
|
||||
|
||||
test('prose that merely contains a number is not a time', () => {
|
||||
assert.deepStrictEqual(timesFromLine('1 pastor, 2 people'), []);
|
||||
assert.deepStrictEqual(timesFromLine('Apollo Beach campus'), []);
|
||||
});
|
||||
|
||||
test('weekday headings, singular or plural, in any case', () => {
|
||||
assert.strictEqual(dayFromLine('Sunday'), 'Sunday');
|
||||
assert.strictEqual(dayFromLine('SUNDAYS'), 'Sunday');
|
||||
assert.strictEqual(dayFromLine(' Saturday '), 'Saturday');
|
||||
assert.strictEqual(dayFromLine('Sunday 9:00a'), '');
|
||||
assert.strictEqual(dayFromLine('Get Involved'), '');
|
||||
});
|
||||
|
||||
test('<br> and block ends become line breaks', () => {
|
||||
assert.deepStrictEqual(htmlToLines('<p>7:30a<br />9:30a</p>'), ['7:30a', '9:30a']);
|
||||
assert.deepStrictEqual(htmlToLines('<p>9:00a & 11:00a</p>'), ['9:00a & 11:00a']);
|
||||
});
|
||||
|
||||
test('the Times slice stops at Get Involved', () => {
|
||||
const section = timesSection(WOONSOCKET);
|
||||
assert.ok(section.includes('9:00a'));
|
||||
assert.ok(!section.includes('Give now'));
|
||||
});
|
||||
|
||||
test('campus slugs are discovered from a sitemap', () => {
|
||||
const xml = `<url><loc>https://waterschurch.org/locations/</loc></url>
|
||||
<url><loc>https://waterschurch.org/locations/north-attleboro/</loc></url>
|
||||
<url><loc>https://waterschurch.org/locations/norwood/</loc></url>`;
|
||||
assert.deepStrictEqual(discoverCampusSlugs(xml), ['north-attleboro', 'norwood']);
|
||||
});
|
||||
|
||||
test('slugs survive punctuation and case', () => {
|
||||
assert.strictEqual(slugify('North Attleboro'), 'north-attleboro');
|
||||
assert.strictEqual(slugify(' Apollo Beach! '), 'apollo-beach');
|
||||
});
|
||||
|
||||
console.log('manual campuses');
|
||||
|
||||
test('name, day and times — day optional', () => {
|
||||
const { campuses, errors } = parseManualCampuses(
|
||||
'Plymouth | Sunday | 9:00a, 11:00a\nQuincy | 10:30a\n'
|
||||
);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.deepStrictEqual(campuses.map((c) => c.name), ['Plymouth', 'Quincy']);
|
||||
assert.deepStrictEqual(campuses[0].services.map((s) => s.text), ['9:00a', '11:00a']);
|
||||
assert.deepStrictEqual(campuses[1].services.map((s) => s.day), ['Sunday']);
|
||||
assert.ok(campuses[0].manual);
|
||||
});
|
||||
|
||||
test('blank lines and # comments are skipped; a line with no time is reported', () => {
|
||||
const { campuses, errors } = parseManualCampuses('\n# not yet on the site\nPlymouth | soon\n');
|
||||
assert.deepStrictEqual(campuses, []);
|
||||
assert.strictEqual(errors.length, 1);
|
||||
assert.match(errors[0], /Line 3/);
|
||||
});
|
||||
|
||||
console.log('merging');
|
||||
|
||||
test('the website wins once it publishes a campus the operator added by hand', () => {
|
||||
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [{ id: '0900', text: '9:00a' }], error: '' }];
|
||||
const manual = parseManualCampuses('Plymouth | 10:30a').campuses;
|
||||
const merged = mergeCampuses(site, manual);
|
||||
assert.strictEqual(merged.length, 1);
|
||||
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['9:00a']);
|
||||
assert.strictEqual(merged[0].manual, false);
|
||||
assert.ok(merged[0].supersededManual, 'the admin page should be able to say the line can go');
|
||||
});
|
||||
|
||||
test('a manual campus the site does not have yet is kept', () => {
|
||||
const merged = mergeCampuses([], parseManualCampuses('Plymouth | 10:30a').campuses);
|
||||
assert.deepStrictEqual(merged.map((c) => c.name), ['Plymouth']);
|
||||
assert.ok(merged[0].manual);
|
||||
});
|
||||
|
||||
test('a site page that parses to nothing falls back to the manual line', () => {
|
||||
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [], error: 'No "Times" section on the page' }];
|
||||
const merged = mergeCampuses(site, parseManualCampuses('Plymouth | 10:30a').campuses);
|
||||
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['10:30a']);
|
||||
assert.strictEqual(merged[0].error, '');
|
||||
assert.ok(merged[0].manual);
|
||||
});
|
||||
|
||||
console.log('resi sources');
|
||||
|
||||
test('campus = broadcast campus, by name or slug, with -> and : accepted', () => {
|
||||
const { feeds, errors } = parseFeeds(
|
||||
'Woonsocket = North Attleboro\nsouthcoast -> north-attleboro\n# a comment\n\nOrlando: Apollo Beach'
|
||||
);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.strictEqual(feeds.get('woonsocket'), 'north-attleboro');
|
||||
assert.strictEqual(feeds.get('southcoast'), 'north-attleboro');
|
||||
assert.strictEqual(feeds.get('orlando'), 'apollo-beach');
|
||||
});
|
||||
|
||||
test('a line that names no source is reported, not dropped in silence', () => {
|
||||
const { feeds, errors } = parseFeeds('woonsocket\nsouthcoast = north-attleboro');
|
||||
assert.strictEqual(feeds.size, 1);
|
||||
assert.strictEqual(errors.length, 1);
|
||||
assert.match(errors[0], /Line 1/);
|
||||
});
|
||||
|
||||
console.log('roles and dependents');
|
||||
|
||||
/** The four campuses production runs, set up the way the booth runs them. */
|
||||
const BOARD = [
|
||||
{ slug: 'north-attleboro', name: 'North Attleboro', services: [{ id: '0730' }, { id: '0930' }, { id: '1130' }] },
|
||||
{ slug: 'woonsocket', name: 'Woonsocket', services: [{ id: '0900' }, { id: '1100' }] },
|
||||
{ slug: 'southcoast', name: 'Southcoast', services: [{ id: '1030' }] },
|
||||
{ slug: 'apollo-beach', name: 'Apollo Beach', services: [{ id: '0900' }, { id: '1100' }] },
|
||||
];
|
||||
const SETUP = {
|
||||
broadcast: ['north-attleboro', 'apollo-beach'],
|
||||
feeds: parseFeeds('woonsocket = north-attleboro\nsouthcoast = north-attleboro').feeds,
|
||||
};
|
||||
const byName = (campuses, name) => campuses.find((c) => c.name === name);
|
||||
|
||||
test('the broadcast campuses originate and take resi from nobody', () => {
|
||||
const roles = assignRoles(BOARD, SETUP);
|
||||
for (const name of ['North Attleboro', 'Apollo Beach']) {
|
||||
assert.strictEqual(byName(roles, name).role, 'broadcast', name);
|
||||
assert.strictEqual(byName(roles, name).source, '', `${name} should have no source`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every other campus takes resi from the campus it is mapped to', () => {
|
||||
const roles = assignRoles(BOARD, SETUP);
|
||||
for (const name of ['Woonsocket', 'Southcoast']) {
|
||||
const campus = byName(roles, name);
|
||||
assert.strictEqual(campus.role, 'resi', name);
|
||||
assert.strictEqual(campus.source, 'north-attleboro', name);
|
||||
assert.strictEqual(campus.sourceName, 'North Attleboro', name);
|
||||
assert.strictEqual(campus.sourceMissing, false, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('an origin lists who is riding on it — the answer the board exists for', () => {
|
||||
const roles = assignRoles(BOARD, SETUP);
|
||||
assert.deepStrictEqual(
|
||||
byName(roles, 'North Attleboro').dependents.map((d) => d.name),
|
||||
['Woonsocket', 'Southcoast']
|
||||
);
|
||||
assert.deepStrictEqual(byName(roles, 'Apollo Beach').dependents, []);
|
||||
});
|
||||
|
||||
test('a source that is not on the board is flagged, not read as unassigned', () => {
|
||||
const roles = assignRoles(BOARD, {
|
||||
broadcast: ['north-attleboro'],
|
||||
feeds: parseFeeds('woonsocket = attleboro-north').feeds, // a typo
|
||||
});
|
||||
const woonsocket = byName(roles, 'Woonsocket');
|
||||
assert.strictEqual(woonsocket.source, 'attleboro-north');
|
||||
assert.strictEqual(woonsocket.sourceMissing, true);
|
||||
assert.deepStrictEqual(byName(roles, 'North Attleboro').dependents, []);
|
||||
});
|
||||
|
||||
test('a campus with no mapping reads as having no source at all', () => {
|
||||
const roles = assignRoles(BOARD, { broadcast: ['north-attleboro'], feeds: new Map() });
|
||||
assert.strictEqual(byName(roles, 'Southcoast').source, '');
|
||||
assert.strictEqual(byName(roles, 'Southcoast').sourceMissing, false);
|
||||
});
|
||||
|
||||
test('assignRoles leaves the campuses it was handed alone', () => {
|
||||
const input = [{ slug: 'woonsocket', name: 'Woonsocket', services: [] }];
|
||||
assignRoles(input, SETUP);
|
||||
assert.strictEqual(input[0].role, undefined, 'the caller’s objects must not be rewritten');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,570 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Service Times — server part.
|
||||
*
|
||||
* Owns the one connection to the church website, keeps every campus's Sunday
|
||||
* service times in memory, and fans them — together with the production marks
|
||||
* an operator puts on a service — out to every tile over SSE. Browsers never
|
||||
* talk to the website.
|
||||
*
|
||||
* GET /state → { state } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* POST /mark → mark one service (replay / do not use / issue)
|
||||
* POST /clear → drop every mark (a fresh week)
|
||||
* POST /refresh → re-read the website now
|
||||
* GET /campuses → { options } — the campuses in play, for the admin
|
||||
* broadcast picker and a tile's campus list
|
||||
* GET /discovered → { options } — every campus page the website publishes,
|
||||
* for the admin "Campuses" picker
|
||||
*
|
||||
* Why the marks live here and not in a tile's settings: a mark is the booth's
|
||||
* shared answer to "is this service usable?", and every campus watching the
|
||||
* board has to see the same one. Per-tile settings are per browser, so they
|
||||
* would let two people disagree about whether the 9:30 is going out. Marks
|
||||
* are written to the ProdDash data directory so a restart mid-morning does
|
||||
* not lose them, and they expire on their own (admin "Marks") so last week's
|
||||
* never greet a new Sunday.
|
||||
*
|
||||
* The website is the source of truth for times. Manual campuses (admin) are a
|
||||
* stopgap for a campus the site has not published yet; the moment the site
|
||||
* carries it, the site's times take over and the admin page says the manual
|
||||
* line can go.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
slugify,
|
||||
} = require('./parse');
|
||||
|
||||
/** Abort a hung page read well before the refresh cadence. */
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
/** Fastest allowed re-read: service times change a few times a year. */
|
||||
const MIN_REFRESH_MINUTES = 5;
|
||||
/** Backoff after a failed read — keep the last good times on screen meanwhile. */
|
||||
const RETRY_MS = 60000;
|
||||
/** Guard against a redirect loop or a runaway campus list. */
|
||||
const MAX_CAMPUSES = 40;
|
||||
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||
|
||||
const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']);
|
||||
const NOTE_LIMIT = 280;
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function sendJson(res, code, body) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > BODY_LIMIT_BYTES) {
|
||||
reject(new Error('Request body too large.'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!data.trim()) return resolve({});
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
resolve(body && typeof body === 'object' ? body : {});
|
||||
} catch {
|
||||
reject(new Error('Malformed JSON.'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||
function guard(fn) {
|
||||
return (req, res) => {
|
||||
Promise.resolve(fn(req, res)).catch((err) => {
|
||||
try {
|
||||
sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) });
|
||||
} catch { /* the client is already gone */ }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where ProdDash keeps per-machine state, resolved the way the shell does.
|
||||
* Never the checkout: the repo folder is replaced on update.
|
||||
*/
|
||||
function resolveDataDir() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||
if (fromEnv) return path.resolve(root, fromEnv);
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||
}
|
||||
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||
}
|
||||
|
||||
const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json');
|
||||
|
||||
function markKey(campus, service) {
|
||||
return `${campus}::${service}`;
|
||||
}
|
||||
|
||||
/* ── admin config shaping ───────────────────────────────────────────── */
|
||||
|
||||
function siteBase(config) {
|
||||
const raw = String(config.siteUrl || '').trim().replace(/\/+$/, '');
|
||||
if (!raw) return '';
|
||||
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
}
|
||||
|
||||
function listSetting(value) {
|
||||
if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean);
|
||||
if (typeof value === 'string') {
|
||||
return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* ── the website ────────────────────────────────────────────────────── */
|
||||
|
||||
async function getText(url) {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = new Error(`${response.status} ${response.statusText || ''}`.trim());
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/** Every campus page the site publishes — the sitemap first, the index page as a fallback. */
|
||||
async function discover(base, log) {
|
||||
const sources = [];
|
||||
for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) {
|
||||
try {
|
||||
sources.push(await getText(base + target));
|
||||
if (target.includes('sitemap')) break; // a sitemap is the fuller list
|
||||
} catch (err) {
|
||||
log(`discovery: ${target} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (!sources.length) return [];
|
||||
return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES);
|
||||
}
|
||||
|
||||
async function readCampus(base, slug) {
|
||||
try {
|
||||
const html = await getText(`${base}/locations/${slug}/`);
|
||||
return parseCampusPage(html, { slug });
|
||||
} catch (err) {
|
||||
return {
|
||||
slug,
|
||||
name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
services: [],
|
||||
error: `Page unreadable: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── module ─────────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const base = siteBase(config);
|
||||
|
||||
const chosen = listSetting(config.campuses);
|
||||
const broadcast = new Set(listSetting(config.broadcast));
|
||||
const { feeds, errors: feedErrors } = parseFeeds(config.feeds);
|
||||
const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual);
|
||||
const retentionHours = Math.max(0, Number(config.retentionHours) || 0);
|
||||
const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000;
|
||||
|
||||
let marks = new Map();
|
||||
let marksWritable = true;
|
||||
let discovered = [];
|
||||
let siteCampuses = [];
|
||||
let reachable = false;
|
||||
let lastError = base ? '' : 'No website address configured';
|
||||
let lastFetched = 0;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let lastFrame = '';
|
||||
|
||||
/* ── marks: load, save, expire ──────────────────────────────────── */
|
||||
|
||||
function loadMarks() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8'));
|
||||
for (const [key, value] of Object.entries(saved?.marks || {})) {
|
||||
if (value && typeof value === 'object') marks.set(key, value);
|
||||
}
|
||||
log(`${marks.size} mark(s) restored`);
|
||||
} catch {
|
||||
/* no marks yet — the usual case */
|
||||
}
|
||||
}
|
||||
|
||||
function saveMarks() {
|
||||
const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true });
|
||||
const tmp = `${MARKS_FILE}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n');
|
||||
fs.renameSync(tmp, MARKS_FILE);
|
||||
marksWritable = true;
|
||||
} catch (err) {
|
||||
// Never fall back to writing inside the checkout: the marks stay in
|
||||
// memory and health() says they will not survive a restart.
|
||||
if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`);
|
||||
marksWritable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop marks older than the retention window. Returns true if any went. */
|
||||
function expireMarks() {
|
||||
if (!retentionHours) return false;
|
||||
const cutoff = Date.now() - retentionHours * 3600000;
|
||||
let dropped = false;
|
||||
for (const [key, mark] of marks) {
|
||||
if (!(Number(mark?.at) > cutoff)) {
|
||||
marks.delete(key);
|
||||
dropped = true;
|
||||
}
|
||||
}
|
||||
if (dropped) saveMarks();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/* ── the view every tile draws ──────────────────────────────────── */
|
||||
|
||||
function campusList() {
|
||||
const wanted = chosen.length
|
||||
? siteCampuses.filter((c) => chosen.includes(c.slug))
|
||||
: siteCampuses;
|
||||
const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds });
|
||||
|
||||
// Marks ride along on the service they belong to.
|
||||
for (const campus of merged) {
|
||||
campus.services = campus.services.map((service) => {
|
||||
const mark = marks.get(markKey(campus.slug, service.id)) || null;
|
||||
return { ...service, mark };
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
configured: Boolean(base),
|
||||
reachable,
|
||||
lastError,
|
||||
lastFetched,
|
||||
siteUrl: base,
|
||||
refreshMinutes: refreshMs / 60000,
|
||||
retentionHours,
|
||||
marksWritable,
|
||||
campuses: campusList(),
|
||||
discovered,
|
||||
manualErrors,
|
||||
feedErrors,
|
||||
now: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastState({ force = false } = {}) {
|
||||
if (!streams.size) return;
|
||||
const view = snapshot();
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(view);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// `now` changes every call; compare without it so idle tiles stay idle.
|
||||
const compare = frame.replace(/,"now":\d+/, '');
|
||||
if (!force && compare === lastFrame) return;
|
||||
lastFrame = compare;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the refresh loop ───────────────────────────────────────────── */
|
||||
|
||||
async function refresh() {
|
||||
if (stopped) return;
|
||||
if (!base) {
|
||||
reachable = false;
|
||||
lastError = 'No website address configured';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const found = await discover(base, log);
|
||||
if (found.length) discovered = found;
|
||||
const slugs = chosen.length ? chosen : discovered;
|
||||
if (!slugs.length) throw new Error('No campus pages found on the site');
|
||||
|
||||
const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug)));
|
||||
const usable = read.filter((campus) => campus.services.length);
|
||||
|
||||
// A total failure keeps the last good times on screen — a site outage
|
||||
// must not blank the board in the middle of a service.
|
||||
if (!usable.length && siteCampuses.length) {
|
||||
throw new Error(read[0]?.error || 'No service times found on the site');
|
||||
}
|
||||
siteCampuses = read;
|
||||
reachable = true;
|
||||
lastFetched = Date.now();
|
||||
const broken = read.filter((campus) => campus.error);
|
||||
lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : '';
|
||||
log(`read ${usable.length}/${read.length} campus page(s) from ${base}`);
|
||||
} catch (err) {
|
||||
reachable = false;
|
||||
lastError = err?.message || String(err);
|
||||
log(`refresh failed: ${lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(delay) {
|
||||
clearTimeout(timer);
|
||||
if (stopped) return;
|
||||
timer = setTimeout(async () => {
|
||||
await refresh();
|
||||
expireMarks();
|
||||
broadcastState();
|
||||
schedule(reachable ? refreshMs : RETRY_MS);
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
loadMarks();
|
||||
expireMarks();
|
||||
if (base) {
|
||||
log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`);
|
||||
} else {
|
||||
log('no website address configured — set one in /admin');
|
||||
}
|
||||
// First read immediately, then on the configured cadence.
|
||||
schedule(0);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
expireMarks();
|
||||
broadcastState();
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = {
|
||||
streams,
|
||||
snapshot,
|
||||
broadcastState,
|
||||
refreshNow: async () => {
|
||||
await refresh();
|
||||
broadcastState({ force: true });
|
||||
},
|
||||
setMark(campusSlug, serviceId, patch) {
|
||||
const key = markKey(slugify(campusSlug), String(serviceId || ''));
|
||||
const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok';
|
||||
if (status === 'ok' && !patch.note && !patch.source) {
|
||||
marks.delete(key);
|
||||
} else {
|
||||
marks.set(key, {
|
||||
status,
|
||||
note: String(patch.note || '').slice(0, NOTE_LIMIT),
|
||||
source: String(patch.source || '').slice(0, 120),
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
saveMarks();
|
||||
broadcastState({ force: true });
|
||||
},
|
||||
clearMarks() {
|
||||
const had = marks.size;
|
||||
marks.clear();
|
||||
saveMarks();
|
||||
broadcastState({ force: true });
|
||||
return had;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(heartbeat);
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!base) return { status: 'error', message: 'No website address configured' };
|
||||
const notes = [];
|
||||
if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable');
|
||||
if (manualErrors.length) notes.push(manualErrors[0]);
|
||||
if (feedErrors.length) notes.push(feedErrors[0]);
|
||||
const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name);
|
||||
if (superseded.length) {
|
||||
notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`);
|
||||
}
|
||||
const suffix = notes.length ? ` — ${notes.join('; ')}` : '';
|
||||
if (!reachable) {
|
||||
return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` };
|
||||
}
|
||||
const count = campusList().reduce((sum, c) => sum + c.services.length, 0);
|
||||
const warn = lastError || notes.length;
|
||||
return {
|
||||
status: warn ? 'connecting' : 'ok',
|
||||
message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The board, plus one entry per campus. A campus booth wants its own times
|
||||
* and the service it is taking resi from — not the whole network — so those
|
||||
* entries fix the campus and drop the campus picker from their gear.
|
||||
*/
|
||||
tiles() {
|
||||
const board = {
|
||||
id: 'board',
|
||||
name: 'Service Times',
|
||||
description: 'Every campus, its services, and what is marked for replay or unusable',
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
settings: {},
|
||||
};
|
||||
const campuses = current ? current.snapshot().campuses : [];
|
||||
return [
|
||||
board,
|
||||
...campuses.map((campus) => ({
|
||||
id: campus.slug,
|
||||
name: campus.name,
|
||||
description: `${campus.name} service times`,
|
||||
defaultSize: { w: 3, h: 4 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
settings: { campuses: [campus.slug] },
|
||||
instanceSchema: {
|
||||
showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' },
|
||||
showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' },
|
||||
showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' },
|
||||
showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' },
|
||||
showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' },
|
||||
showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' },
|
||||
},
|
||||
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
||||
})),
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
sendJson(res, 200, { state: current ? current.snapshot() : null });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||
// not queue frames forever; keepalive probes make the OS notice.
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /mark': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
const body = await readJsonBody(req);
|
||||
const campus = String(body.campus || '').trim();
|
||||
const service = String(body.service || '').trim();
|
||||
if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' });
|
||||
current.setMark(campus, service, {
|
||||
status: body.status,
|
||||
note: body.note,
|
||||
source: body.source,
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
}),
|
||||
|
||||
'POST /clear': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
sendJson(res, 200, { ok: true, cleared: current.clearMarks() });
|
||||
}),
|
||||
|
||||
'POST /refresh': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
await current.refreshNow();
|
||||
sendJson(res, 200, { ok: true, state: current.snapshot() });
|
||||
}),
|
||||
|
||||
'GET /campuses': (req, res) => {
|
||||
const campuses = current ? current.snapshot().campuses : [];
|
||||
sendJson(res, 200, {
|
||||
options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })),
|
||||
});
|
||||
},
|
||||
|
||||
'GET /discovered': (req, res) => {
|
||||
const state = current ? current.snapshot() : null;
|
||||
const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name]));
|
||||
const slugs = state?.discovered?.length ? state.discovered : [...names.keys()];
|
||||
sendJson(res, 200, {
|
||||
options: slugs.map((slug) => ({
|
||||
value: slug,
|
||||
label: names.get(slug)
|
||||
|| slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
})),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
/* Service Times — scoped under its tiles via [data-module="service-times"],
|
||||
styled with the shell's CSS variables only so themes reach into it. */
|
||||
|
||||
[data-module="service-times"] .st-board {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* ── a campus ─────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-campus {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--text) 8%);
|
||||
padding: 7px 8px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-is-broadcast {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-campus-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-campus-name {
|
||||
margin: 0;
|
||||
font-size: 1.02em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role {
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role-broadcast {
|
||||
color: var(--accent-text, var(--text));
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role-manual {
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-role-unset {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-feeds {
|
||||
margin: 0 0 5px;
|
||||
font-size: 0.76em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── a service ────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
border: 1px solid transparent;
|
||||
border-left: 3px solid color-mix(in srgb, var(--text) 22%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 4px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-row:hover,
|
||||
[data-module="service-times"] .st-row:focus-visible {
|
||||
border-color: var(--border);
|
||||
border-left-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-row.st-open {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-time {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-day {
|
||||
font-size: 0.74em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-issue,
|
||||
[data-module="service-times"] .st-chip-warn {
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-replay {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-skip {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-chip-source,
|
||||
[data-module="service-times"] .st-chip-quiet {
|
||||
color: var(--muted);
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Marked services read at a glance from across the booth. */
|
||||
[data-module="service-times"] .st-row.st-issue { border-left-color: var(--warn); }
|
||||
[data-module="service-times"] .st-row.st-replay { border-left-color: var(--accent); }
|
||||
[data-module="service-times"] .st-row.st-skip {
|
||||
border-left-color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
[data-module="service-times"] .st-row.st-skip .st-time {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
[data-module="service-times"] .st-row.st-affected {
|
||||
border-left-color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-note {
|
||||
flex: 1 0 100%;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── messages ─────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-empty,
|
||||
[data-module="service-times"] .st-updated {
|
||||
margin: 4px 2px 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-warn {
|
||||
margin: 6px 2px 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* ── the mark editor ──────────────────────────────────────────────── */
|
||||
|
||||
[data-module="service-times"] .st-editor {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-pick {
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-pick:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-pick.active,
|
||||
[data-module="service-times"] .st-save {
|
||||
color: var(--accent-text, var(--text));
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-label {
|
||||
font-size: 0.72em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-input {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
[data-module="service-times"] .st-editor-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
+121
-42
@@ -17,6 +17,21 @@
|
||||
|
||||
const FLOOR = -200;
|
||||
|
||||
/**
|
||||
* A dead input reads as a number, and a number on a wall is believed. Every
|
||||
* tile therefore refuses to draw a level while the server says the input is
|
||||
* digitally silent, and says why instead.
|
||||
*/
|
||||
function silentFor(payload) {
|
||||
return payload && payload.silentSec ? payload.silentSec : 0;
|
||||
}
|
||||
|
||||
function silentText(sec) {
|
||||
if (sec < 90) return `No audio — ${sec} s`;
|
||||
const m = Math.round(sec / 60);
|
||||
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
|
||||
}
|
||||
|
||||
/** Retention per frame for the RTA's smoothing setting. */
|
||||
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||
|
||||
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.setStatus('error', payload.error);
|
||||
} else if (!payload.frame) {
|
||||
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||
} else if (silentFor(payload)) {
|
||||
moduleApi.setStatus('error',
|
||||
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
|
||||
} else if (!payload.calibrated) {
|
||||
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||
} else {
|
||||
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
|
||||
render(payload) {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const ch = pickChannel(payload, s);
|
||||
const quiet = silentFor(payload);
|
||||
if (quiet && ch) {
|
||||
num.textContent = '––.–';
|
||||
unit.textContent = '';
|
||||
sub.hidden = false;
|
||||
sub.textContent = silentText(quiet);
|
||||
box.dataset.level = 'dead';
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
if (!payload || payload.error || !ch) {
|
||||
num.textContent = '––.–';
|
||||
sub.textContent = payload && payload.error ? payload.error : '';
|
||||
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
|
||||
</div>`).join('');
|
||||
}
|
||||
const s = moduleApi.instanceSettings;
|
||||
const quiet = silentFor(payload);
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
const showPeak = s.peakHold !== false;
|
||||
const lo = payload.calibrated ? 40 : -80;
|
||||
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
|
||||
if (!el) continue;
|
||||
const value = readValue(c, { averaging: s.averaging });
|
||||
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
|
||||
el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
|
||||
const peak = el.querySelector('.spl-bar-peak');
|
||||
peak.style.display = showPeak ? '' : 'none';
|
||||
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
||||
el.querySelector('.spl-bar-value').textContent = fmt(value);
|
||||
el.dataset.level = !payload.calibrated ? 'idle'
|
||||
el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
|
||||
el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
el.classList.toggle('clip', !!c.clip);
|
||||
}
|
||||
@@ -321,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
|
||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||
const canvas = root.querySelector('.spl-canvas');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
let painted = false; // spectrogram: has the background been laid down
|
||||
/**
|
||||
* The spectrogram's scrolling history, on its own surface.
|
||||
*
|
||||
* It used to scroll by copying the visible canvas onto itself, which also
|
||||
* copied whatever had been drawn on top of it — so every frame dragged the
|
||||
* frequency labels and gridlines one step further along and left a smear.
|
||||
* Only measured pixels belong on a surface that scrolls; annotations are
|
||||
* drawn fresh over the top of it, every frame, and never into it.
|
||||
*/
|
||||
let history = null;
|
||||
let historyCtx = null;
|
||||
let painted = false; // spectrogram: has the history been cleared yet
|
||||
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||
let smoothedFor = '';
|
||||
let axisBtn = null;
|
||||
@@ -389,10 +429,12 @@ function spectrumView(root, moduleApi, scrolling) {
|
||||
const muted = themeColor(root, '--muted', '#888');
|
||||
const border = themeColor(root, '--border', '#333');
|
||||
|
||||
if (!payload || !payload.frame || !payload.frame.bands) {
|
||||
const quiet = silentFor(payload);
|
||||
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
|
||||
ctx.fillText(quiet ? silentText(quiet)
|
||||
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
|
||||
painted = false;
|
||||
return;
|
||||
}
|
||||
@@ -466,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
|
||||
// ── spectrogram ──
|
||||
const timeX = axis() === 'x';
|
||||
const colW = 2;
|
||||
const gutter = showLabels ? axisPad : 0;
|
||||
// Frequency occupies whichever axis time does not.
|
||||
const freqExtent = timeX ? h - gutter : w - gutter;
|
||||
const timeExtent = timeX ? w : h;
|
||||
// A label along the bottom needs only its line height; one down the
|
||||
// side has to fit "12.5k" beside the plot rather than on top of it.
|
||||
const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
|
||||
// Labels belong beside the frequency axis: down the left when frequency
|
||||
// runs vertically, along the bottom when it runs horizontally.
|
||||
const plotX = timeX ? gutter : 0;
|
||||
const plotW = timeX ? w - gutter : w;
|
||||
const plotH = timeX ? h : h - gutter;
|
||||
const freqExtent = timeX ? plotH : plotW;
|
||||
const timeExtent = timeX ? plotW : plotH;
|
||||
if (plotW < 4 || plotH < 4) return;
|
||||
|
||||
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
|
||||
|
||||
// Source rect in device pixels, destination in CSS pixels: the context
|
||||
// carries a devicePixelRatio transform but the canvas as an image
|
||||
// source does not, and letting drawImage infer them scales the picture
|
||||
// up and pushes every column off the canvas.
|
||||
if (timeX) {
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
||||
} else {
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
|
||||
// The history surface is sized to the plot, in device pixels, and is
|
||||
// thrown away whenever that geometry changes — a scroll buffer means
|
||||
// nothing once it is being stretched into a different shape.
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const needW = Math.max(1, Math.round(plotW * dpr));
|
||||
const needH = Math.max(1, Math.round(plotH * dpr));
|
||||
if (!history || history.width !== needW || history.height !== needH) {
|
||||
history = document.createElement('canvas');
|
||||
history.width = needW;
|
||||
history.height = needH;
|
||||
historyCtx = history.getContext('2d', { alpha: false });
|
||||
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
painted = false;
|
||||
}
|
||||
if (!painted) {
|
||||
historyCtx.fillStyle = bg;
|
||||
historyCtx.fillRect(0, 0, plotW, plotH);
|
||||
painted = true;
|
||||
}
|
||||
|
||||
// Scroll the history, then add the newest column. The source rectangle
|
||||
// is in device pixels and the destination in CSS pixels, because the
|
||||
// context carries the dpr transform but the canvas as an image source
|
||||
// does not.
|
||||
historyCtx.drawImage(history, 0, 0, history.width, history.height,
|
||||
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
|
||||
|
||||
const bandExtent = freqExtent / values.length;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||
historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||
if (timeX) {
|
||||
// Low frequencies at the bottom, as every spectrogram has them.
|
||||
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||
colW, Math.ceil(bandExtent) + 1);
|
||||
historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||
colW, Math.ceil(bandExtent) + 1);
|
||||
} else {
|
||||
// Low frequencies at the left.
|
||||
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
|
||||
Math.ceil(bandExtent) + 1, colW);
|
||||
historyCtx.fillRect(i * bandExtent, timeExtent - colW,
|
||||
Math.ceil(bandExtent) + 1, colW);
|
||||
}
|
||||
}
|
||||
|
||||
if (grid || showLabels) {
|
||||
const posOf = (f) => {
|
||||
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
|
||||
};
|
||||
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
|
||||
ctx.fillStyle = bg;
|
||||
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
|
||||
else ctx.fillRect(0, 0, gutter, h);
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
|
||||
// Repaint the visible canvas from scratch every frame: history first,
|
||||
// annotations over it. Nothing drawn here can ever reach the scroll.
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(history, 0, 0, history.width, history.height,
|
||||
plotX, 0, plotW, plotH);
|
||||
|
||||
if (!grid && !showLabels) return;
|
||||
|
||||
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||
const posOf = (f) => {
|
||||
const t = Math.log2(f / freqs[0]) / span;
|
||||
return timeX ? plotH - t * plotH : plotX + t * plotW;
|
||||
};
|
||||
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
|
||||
|
||||
if (grid) {
|
||||
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
|
||||
ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
if (timeX) {
|
||||
const y = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||
ctx.moveTo(plotX, y); ctx.lineTo(w, y);
|
||||
} else {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||
}
|
||||
}
|
||||
ctx.stroke(); ctx.globalAlpha = 1;
|
||||
ctx.textAlign = timeX ? 'left' : 'center';
|
||||
for (const l of showLabels ? labels : []) {
|
||||
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
|
||||
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||
ctx.textAlign = timeX ? 'right' : 'center';
|
||||
for (const l of labels) {
|
||||
if (timeX) {
|
||||
ctx.fillText(hzLabel(l.f), gutter - 2,
|
||||
Math.max(7, Math.min(h - 2, l.pos + 3)));
|
||||
} else {
|
||||
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
|
||||
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
|
||||
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const PING_MS = 15000;
|
||||
|
||||
/**
|
||||
* Below this, unweighted, the input is not quiet — it is not there.
|
||||
*
|
||||
* A real input always carries something: a microphone in a silent room still
|
||||
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
|
||||
* a stream of actual zeros reaches the capture tool's -200 floor, and that
|
||||
* means the device handed us nothing at all.
|
||||
*/
|
||||
const SILENCE_DBFS = -150;
|
||||
/** How long that has to hold before saying so, so a gap between buffers or a
|
||||
device changing sample rate mid-stream doesn't cry wolf. */
|
||||
const SILENCE_GRACE_MS = 5000;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||
let state = null;
|
||||
|
||||
@@ -100,6 +113,7 @@ module.exports = {
|
||||
meta: null,
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
@@ -143,6 +157,12 @@ module.exports = {
|
||||
state.error = '';
|
||||
state.frame = update.frame;
|
||||
state.meta = update.meta;
|
||||
// Digital silence on every channel is a dead input, not a reading.
|
||||
// Saying "ok" through this is what made a stopped Dante device look
|
||||
// like a frozen module for a day and a half.
|
||||
const dead = update.frame.ch.every(
|
||||
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
|
||||
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
@@ -159,6 +179,11 @@ module.exports = {
|
||||
health() {
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const quiet = silentMs();
|
||||
if (quiet > SILENCE_GRACE_MS) {
|
||||
return { status: 'error',
|
||||
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
|
||||
}
|
||||
const n = state.channels.length;
|
||||
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||
return state.calibrated
|
||||
@@ -351,10 +376,26 @@ module.exports = {
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/** How long every channel has been at digital silence, in ms. */
|
||||
function silentMs() {
|
||||
return state && state.silentSince ? Date.now() - state.silentSince : 0;
|
||||
}
|
||||
|
||||
function describe(ms) {
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 90) return `${s} s`;
|
||||
const m = Math.round(s / 60);
|
||||
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
|
||||
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
|
||||
// changed every frame would make the fan-out's change check useless.
|
||||
const quiet = silentMs();
|
||||
return {
|
||||
error: state.error,
|
||||
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
|
||||
calibrated: state.calibrated,
|
||||
channels: state.channels,
|
||||
weighting: state.meta ? state.meta.weighting : '',
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
||||
|
||||
/* A dead input reads danger, not muted: silence that is a fault should not
|
||||
look like silence that is a quiet room. */
|
||||
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
|
||||
|
||||
/* ── level bars ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-bars {
|
||||
@@ -100,6 +105,7 @@
|
||||
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
||||
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
|
||||
|
||||
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||
visible when the bar falls away underneath it. */
|
||||
|
||||
Reference in New Issue
Block a user