Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0aee5abf | ||
|
|
6d0f8828c1 | ||
|
|
8ca88ba10b | ||
|
|
a8d1b8e159 | ||
|
|
82c552955d | ||
|
|
ea3a920d5e | ||
|
|
f542b34b6c | ||
|
|
a1ea9262c6 | ||
|
|
098562c088 | ||
|
|
408dea7923 | ||
|
|
3ca960baf6 | ||
|
|
882a418a0f |
@@ -323,7 +323,10 @@ below. Nothing is installed without someone clicking.
|
||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
||||
**Plan** (the order of service) and **Teams** (who is scheduled).
|
||||
|
||||
**Plan** shows every
|
||||
plan item with its scheduled start time, length, type, song key,
|
||||
description and item notes. When the ProPresenter module is running it
|
||||
follows that module's live item, highlights the matching plan item, dims
|
||||
@@ -342,6 +345,17 @@ below. Nothing is installed without someone clicking.
|
||||
(gear menu): the top-bar text (placeholders such as
|
||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||
show/hide checklist for every detail.
|
||||
|
||||
**Teams** shows the same plan's team and position assignments — each team
|
||||
as a band of its people, with their photo (fetched through ProdDash, never
|
||||
by the browser; initials when Planning Center has none) and a dot for
|
||||
confirmed, unconfirmed or declined. Its gear menu picks which teams and
|
||||
which positions appear, groups people under their position or lists them
|
||||
plainly, and sets the colours: one for the team headers, or **Tint teams**
|
||||
to give each team its own, optionally carried into the rows. Teams nobody
|
||||
has signed up for are hidden until you ask for them — that list comes from
|
||||
the service type, since Planning Center only returns a team once somebody
|
||||
is on it.
|
||||
- **Slack** — live transcripts of this location's Slack channels, plus
|
||||
PIN-protected sending as the location. The + picker lists one transcript
|
||||
tile per configured channel, titled `#channel`, and two send surfaces (one
|
||||
@@ -365,15 +379,57 @@ 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.
|
||||
- **Resi Cues** — how long until the next Resi cue point, for a campus
|
||||
running video from another site. The tile is one number, as large as it
|
||||
will go: the countdown to the next cue, its name above it, and the playhead
|
||||
and the cue just passed underneath. It also offers that countdown and the
|
||||
playhead to the **Timers** module, so they can sit among the ProPresenter
|
||||
timers instead of in a tile of their own.
|
||||
Both the cue list and the decoder's position come from Resi's cloud rather
|
||||
than the decoder — the decoder reports upstream and central.resi.io hands it
|
||||
back — so nothing is installed at the decoder end. Resi republishes a
|
||||
position only every few seconds and each reading is already stale, so the
|
||||
playhead is estimated between readings rather than repeated; a countdown
|
||||
built straight from Resi's number sits seconds behind and moves in lurches.
|
||||
Admin config: Resi login and password, which decoder to follow (the list is
|
||||
read from the account), and a folded **Timing** group for the poll
|
||||
intervals and a position trim.
|
||||
Tile settings: cue name, previous cue, playhead, cue list.
|
||||
- **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.
|
||||
@@ -1,5 +1,9 @@
|
||||
/* PCO Plan — client part.
|
||||
|
||||
The module presents two tiles and this is the entry for both: the Teams
|
||||
tile (picker entry "teams") is built by teams-client.js, everything below
|
||||
draws the Plan tile.
|
||||
|
||||
The server part reads Planning Center and (optionally) follows the
|
||||
ProPresenter module's live item; this tile only renders the plan it is
|
||||
streamed. What is shown is this tile's own choice (gear menu →
|
||||
@@ -8,6 +12,8 @@
|
||||
(saved with the tile, 14 px until someone presses one); /admin only holds
|
||||
the connection settings. */
|
||||
|
||||
import createTeams from "./teams-client.js";
|
||||
|
||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||
|
||||
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||
@@ -15,6 +21,12 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
// Tiles added as "Teams" carry that variant and have their own settings.
|
||||
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
||||
return createPlan({ root, moduleApi });
|
||||
}
|
||||
|
||||
function createPlan({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let skew = 0; // server clock − local clock, ms
|
||||
let feedOffline = false;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service from Planning Center Services",
|
||||
"description": "Order of service and team assignments from Planning Center Services",
|
||||
"provides": [
|
||||
"timers"
|
||||
],
|
||||
@@ -383,5 +383,232 @@
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tiles": [
|
||||
{
|
||||
"id": "plan",
|
||||
"name": "Plan",
|
||||
"description": "The order of service, item by item",
|
||||
"defaultSize": {
|
||||
"w": 5,
|
||||
"h": 6
|
||||
},
|
||||
"minSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "teams",
|
||||
"name": "Teams",
|
||||
"description": "Who is scheduled, by team and position",
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 6
|
||||
},
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"instanceSchema": {
|
||||
"columns": {
|
||||
"type": "select",
|
||||
"label": "Columns",
|
||||
"default": "auto",
|
||||
"options": [
|
||||
{
|
||||
"value": "auto",
|
||||
"label": "Fit the tile"
|
||||
},
|
||||
{
|
||||
"value": "1",
|
||||
"label": "One"
|
||||
},
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Two"
|
||||
},
|
||||
{
|
||||
"value": "3",
|
||||
"label": "Three"
|
||||
}
|
||||
]
|
||||
},
|
||||
"teams": {
|
||||
"type": "multiselect",
|
||||
"label": "Teams",
|
||||
"columns": 2,
|
||||
"allByDefault": true,
|
||||
"optionsRoute": "/teams"
|
||||
},
|
||||
"headerTemplate": {
|
||||
"type": "template",
|
||||
"label": "Top bar text",
|
||||
"default": "{serviceType} • {date} | {people} on {teams}",
|
||||
"group": "Top bar",
|
||||
"variables": [
|
||||
{
|
||||
"value": "{series}",
|
||||
"label": "Series"
|
||||
},
|
||||
{
|
||||
"value": "{part}",
|
||||
"label": "Part / plan title"
|
||||
},
|
||||
{
|
||||
"value": "{serviceType}",
|
||||
"label": "Service type"
|
||||
},
|
||||
{
|
||||
"value": "{date}",
|
||||
"label": "Date"
|
||||
},
|
||||
{
|
||||
"value": "{shortDate}",
|
||||
"label": "Short date"
|
||||
},
|
||||
{
|
||||
"value": "{time}",
|
||||
"label": "Service time"
|
||||
},
|
||||
{
|
||||
"value": "{people}",
|
||||
"label": "People count"
|
||||
},
|
||||
{
|
||||
"value": "{teams}",
|
||||
"label": "Team count"
|
||||
},
|
||||
{
|
||||
"value": "{confirmed}",
|
||||
"label": "Confirmed count"
|
||||
}
|
||||
],
|
||||
"help": "Use • between items and | to start a second line."
|
||||
},
|
||||
"teamColor": {
|
||||
"type": "color",
|
||||
"label": "Team headers",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintTeams": {
|
||||
"type": "boolean",
|
||||
"label": "Tint teams",
|
||||
"default": false,
|
||||
"group": "Colors"
|
||||
},
|
||||
"tintRows": {
|
||||
"type": "boolean",
|
||||
"label": "Tint rows too",
|
||||
"default": false,
|
||||
"group": "Colors",
|
||||
"showWhen": "tintTeams"
|
||||
},
|
||||
"headerGradient": {
|
||||
"type": "boolean",
|
||||
"label": "Header fade",
|
||||
"default": true,
|
||||
"group": "Colors"
|
||||
},
|
||||
"confirmedColor": {
|
||||
"type": "color",
|
||||
"label": "Confirmed",
|
||||
"default": "#2ee59a",
|
||||
"group": "Colors"
|
||||
},
|
||||
"unconfirmedColor": {
|
||||
"type": "color",
|
||||
"label": "Unconfirmed",
|
||||
"default": "#f0b429",
|
||||
"group": "Colors"
|
||||
},
|
||||
"declinedColor": {
|
||||
"type": "color",
|
||||
"label": "Declined",
|
||||
"default": "#f4433c",
|
||||
"group": "Colors"
|
||||
},
|
||||
"showTopBar": {
|
||||
"type": "boolean",
|
||||
"label": "Top bar",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTeamHeaders": {
|
||||
"type": "boolean",
|
||||
"label": "Team headers",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showTeamCounts": {
|
||||
"type": "boolean",
|
||||
"label": "Team counts",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Avatars",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showPosition": {
|
||||
"type": "boolean",
|
||||
"label": "Positions",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"groupByPosition": {
|
||||
"type": "boolean",
|
||||
"label": "Group by position",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showStatus": {
|
||||
"type": "boolean",
|
||||
"label": "Status",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showNotes": {
|
||||
"type": "boolean",
|
||||
"label": "Person notes",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showDeclined": {
|
||||
"type": "boolean",
|
||||
"label": "Declined people",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showUnconfirmed": {
|
||||
"type": "boolean",
|
||||
"label": "Unconfirmed people",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"showEmptyTeams": {
|
||||
"type": "boolean",
|
||||
"label": "Teams with nobody",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Top bar": {
|
||||
"collapsed": false
|
||||
},
|
||||
"Colors": {
|
||||
"collapsed": false,
|
||||
"columns": 2
|
||||
},
|
||||
"Show": {
|
||||
"collapsed": true,
|
||||
"columns": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* (documents ProPresenter has shown or lists — see below)
|
||||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
||||
* the service type's item note categories, by name
|
||||
* GET /teams → { options } for a Teams tile's "Teams" switches
|
||||
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
|
||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||
*
|
||||
@@ -29,6 +31,7 @@
|
||||
* GET …/plans/{id}/plan_times (service start times)
|
||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||
* GET /service_types/{st}/item_note_categories (note category names)
|
||||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
||||
*
|
||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||
* this module reads the propresenter-now-next module's own SSE feed over
|
||||
@@ -85,6 +88,19 @@ const NOW_NEXT_RETRY_MS = 3000;
|
||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||
const SEEN_DOCUMENTS_MAX = 200;
|
||||
/**
|
||||
* Team assignments change far more slowly than the plan itself (someone
|
||||
* confirms, a position is filled), so they ride a slow lane: re-read when the
|
||||
* plan changes, and otherwise no more often than this.
|
||||
*/
|
||||
const TEAMS_MIN_MS = 60 * 1000;
|
||||
/** How long the service type's own team list is believed. */
|
||||
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
||||
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
||||
const AVATAR_CACHE_MAX = 300;
|
||||
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
||||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
||||
/**
|
||||
@@ -112,6 +128,8 @@ function clearedState() {
|
||||
serviceType: { id: '', name: '' },
|
||||
plan: null,
|
||||
items: [],
|
||||
teams: [], // who is scheduled, by team (the Teams tile)
|
||||
teamsError: '', // why the last team read failed, if it did
|
||||
live: clearedLive(),
|
||||
service: clearedService(),
|
||||
timing: null, // the admin's service-timing choices (no secrets)
|
||||
@@ -372,6 +390,168 @@ function shapePlan(planRec, serviceTypeId, planTimes) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── team assignments (the Teams tile) ──────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Planning Center's scheduling status letters. The API answers "C", "U" or
|
||||
* "D"; some accounts and older records spell the word out, so match on the
|
||||
* first letter and treat anything unknown as not yet confirmed — the state a
|
||||
* booth should chase.
|
||||
*/
|
||||
function teamStatusOf(raw) {
|
||||
const s = String(raw || '').trim().toUpperCase();
|
||||
if (s.startsWith('C')) return 'confirmed';
|
||||
if (s.startsWith('D')) return 'declined';
|
||||
return 'unconfirmed';
|
||||
}
|
||||
|
||||
/**
|
||||
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
|
||||
* plan is read with `include=person` the Person record carries the fuller set
|
||||
* of names Planning Center has used for the same thing over the years. Take
|
||||
* the first that looks like a URL — a missing photo is normal and the tile
|
||||
* draws initials instead.
|
||||
*/
|
||||
function teamPhotoOf(attrs, person) {
|
||||
const candidates = [
|
||||
attrs?.photo_thumbnail,
|
||||
attrs?.photo_url,
|
||||
person?.attributes?.avatar,
|
||||
person?.attributes?.photo_thumbnail_url,
|
||||
person?.attributes?.photo_url,
|
||||
person?.attributes?.thumbnail_url,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const url = String(c || '').trim();
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
|
||||
* each with its people. Teams keep Planning Center's own order (a Team's
|
||||
* `sequence`, then name); people sort by position, then by name, so a tile
|
||||
* reads the way the schedule page does.
|
||||
*
|
||||
* Everyone scheduled belongs to a team, but a record whose team relationship
|
||||
* is missing (a deleted team, a partial response) is not dropped on the
|
||||
* floor — it lands under "Unassigned" where somebody will notice it.
|
||||
*
|
||||
* `allTeams` is the service type's own team list. Planning Center only
|
||||
* returns a team here once somebody is scheduled on it, so without that list
|
||||
* a team nobody has signed up for is invisible — which is the one a booth
|
||||
* most wants to see. It also carries the authoritative name and order.
|
||||
*/
|
||||
function shapeTeams(body, allTeams) {
|
||||
const included = new Map();
|
||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||
|
||||
const teams = new Map();
|
||||
for (const pp of body.data || []) {
|
||||
const a = pp.attributes || {};
|
||||
const teamRef = pp.relationships?.team?.data;
|
||||
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
|
||||
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
|
||||
if (!teams.has(teamId)) {
|
||||
teams.set(teamId, {
|
||||
id: teamId,
|
||||
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
|
||||
sequence: Number(teamRec?.attributes?.sequence) || 0,
|
||||
members: [],
|
||||
});
|
||||
}
|
||||
const personRef = pp.relationships?.person?.data;
|
||||
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
|
||||
teams.get(teamId).members.push({
|
||||
id: String(pp.id),
|
||||
personId: personRef?.id ? String(personRef.id) : '',
|
||||
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
|
||||
position: String(a.team_position_name || '').trim(),
|
||||
status: teamStatusOf(a.status),
|
||||
declineReason: String(a.decline_reason || '').trim(),
|
||||
notes: String(a.notes || '').trim(),
|
||||
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
|
||||
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
|
||||
});
|
||||
}
|
||||
|
||||
// Teams nobody is scheduled on yet, and the real name/order for the rest.
|
||||
for (const t of allTeams || []) {
|
||||
const known = teams.get(t.id);
|
||||
if (known) {
|
||||
if (t.name) known.name = t.name;
|
||||
known.sequence = t.sequence;
|
||||
} else {
|
||||
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const out = [...teams.values()];
|
||||
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
|
||||
for (const team of out) {
|
||||
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
|
||||
team.counts = {
|
||||
total: team.members.length,
|
||||
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
|
||||
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
|
||||
declined: team.members.filter((m) => m.status === 'declined').length,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
|
||||
ever talks to Planning Center (same reasoning as every other upstream). */
|
||||
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
|
||||
const avatarInFlight = new Map();
|
||||
|
||||
function fetchAvatar(url) {
|
||||
const hit = avatarCache.get(url);
|
||||
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
|
||||
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
|
||||
const p = (async () => {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
|
||||
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
|
||||
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
|
||||
const entry = { buf, type, at: Date.now() };
|
||||
avatarCache.set(url, entry);
|
||||
return entry;
|
||||
})().finally(() => avatarInFlight.delete(url));
|
||||
avatarInFlight.set(url, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
|
||||
function avatarUrlAllowed(url) {
|
||||
try {
|
||||
const u = new URL(String(url || ''));
|
||||
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
|
||||
async function serveAvatar(res, url) {
|
||||
if (!avatarUrlAllowed(url)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||
return void res.end('No photo');
|
||||
}
|
||||
try {
|
||||
const img = await fetchAvatar(url);
|
||||
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
|
||||
res.end(img.buf);
|
||||
} catch {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||
res.end('Photo unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
|
||||
function serviceLabel(win) {
|
||||
if (!win || !win.startsAt) return '';
|
||||
@@ -549,6 +729,98 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
/* — team assignments (the Teams tile) — */
|
||||
|
||||
/**
|
||||
* The service type's teams, cached: they change when somebody edits the
|
||||
* service type, not during a service. A failed read is not fatal — the
|
||||
* plan's own teams still draw, just without the empty ones.
|
||||
*/
|
||||
let serviceTeams = { at: 0, list: [] };
|
||||
|
||||
async function readServiceTeams() {
|
||||
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
||||
try {
|
||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
|
||||
const list = (body.data || []).map((t, i) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
||||
sequence: Number(t.attributes?.sequence) || i + 1,
|
||||
}));
|
||||
serviceTeams = { at: Date.now(), list };
|
||||
} catch (err) {
|
||||
log(`service type team list read failed: ${err?.message || err}`);
|
||||
serviceTeams.at = Date.now(); // don't retry every poll
|
||||
}
|
||||
return serviceTeams.list;
|
||||
}
|
||||
|
||||
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
||||
let teamPhotos = new Map();
|
||||
let teamsReadAt = 0;
|
||||
|
||||
/**
|
||||
* Who is scheduled on this plan. Asked for with `include=team,person` and
|
||||
* read again defensively without the includes when an account refuses
|
||||
* them, exactly as the items read does: a tile that can name people
|
||||
* without photos is far better than a tile that shows nothing.
|
||||
*/
|
||||
async function readTeams(planId) {
|
||||
const target = `${planPath(planId)}/team_members`;
|
||||
let body;
|
||||
try {
|
||||
body = await pco.getAll(target, { include: 'team,person' });
|
||||
} catch (err) {
|
||||
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||||
log(`team include rejected (${err.message}) — reading team members without includes`);
|
||||
body = await pco.getAll(target);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const teams = shapeTeams(body, await readServiceTeams());
|
||||
const photos = new Map();
|
||||
const included = new Map();
|
||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||
for (const pp of body.data || []) {
|
||||
const personRef = pp.relationships?.person?.data;
|
||||
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
||||
if (url) photos.set(String(pp.id), url);
|
||||
}
|
||||
teamPhotos = photos;
|
||||
teamsReadAt = Date.now();
|
||||
return teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the team list when it is worth it: a new plan, or the slow lane
|
||||
* coming round. A failure keeps the last good list and says so in the
|
||||
* state — the plan itself is what the booth needs, and losing the roster
|
||||
* must never cost them the running order.
|
||||
*/
|
||||
async function refreshTeams(planId, force) {
|
||||
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
|
||||
try {
|
||||
const teams = await readTeams(planId);
|
||||
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
|
||||
const after = teams.reduce((n, t) => n + t.members.length, 0);
|
||||
state.teams = teams;
|
||||
state.teamsError = '';
|
||||
if (force || before !== after) {
|
||||
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
state.teamsError = err?.message || String(err);
|
||||
log(`team read failed: ${state.teamsError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
||||
function teamOptions() {
|
||||
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
||||
}
|
||||
|
||||
|
||||
async function readPlan() {
|
||||
if (!pco.configured || !serviceTypeId) return;
|
||||
let planRec = null;
|
||||
@@ -573,6 +845,8 @@ module.exports = {
|
||||
if (state.plan) log('no plan found any more — service timing reset');
|
||||
state.plan = null;
|
||||
state.items = [];
|
||||
state.teams = [];
|
||||
state.teamsError = "";
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -589,6 +863,8 @@ module.exports = {
|
||||
state.plan = plan;
|
||||
state.items = items;
|
||||
noteMessageItem(items);
|
||||
// The roster rides its own slow lane and never fails the plan read.
|
||||
await refreshTeams(plan.id, planChanged);
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -1293,6 +1569,8 @@ module.exports = {
|
||||
timersSnapshot,
|
||||
documentOptions,
|
||||
noteCategoryOptions,
|
||||
teamOptions,
|
||||
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1385,6 +1663,28 @@ module.exports = {
|
||||
req.on('close', () => timerStreams.delete(res));
|
||||
},
|
||||
|
||||
/**
|
||||
* Options for a Teams tile's "Teams" switches. Like the note
|
||||
* categories, always a 200 with a list: an empty one simply means there
|
||||
* is nothing scheduled to choose from yet, which the gear window says
|
||||
* plainly.
|
||||
*/
|
||||
'GET /teams': (req, res) => {
|
||||
json(res, 200, current ? current.teamOptions() : { options: [] });
|
||||
},
|
||||
|
||||
/**
|
||||
* A scheduled person's photo, proxied from Planning Center and cached.
|
||||
* The tile asks by plan-person id and never sees the CDN URL; an id the
|
||||
* module does not know (an old tile, a person since unscheduled) is a
|
||||
* 404 and the tile falls back to initials.
|
||||
*/
|
||||
'GET /team-avatar/*': (req, res) => {
|
||||
const id = String(req.wildcard || '').replace(/^\/+/, '');
|
||||
const url = current ? current.avatarUrlFor(id) : '';
|
||||
serveAvatar(res, url);
|
||||
},
|
||||
|
||||
/** Options for the admin "Start document" / "End document" selects. */
|
||||
'GET /documents': (req, res) => {
|
||||
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
|
||||
|
||||
@@ -365,3 +365,227 @@
|
||||
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
|
||||
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
|
||||
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
|
||||
|
||||
/* ── the Teams tile ───────────────────────────────────────────────────
|
||||
Who is scheduled, by team. Sizes hang off --pp-size like the Plan tile;
|
||||
colours come from the tile's own settings (--tm-team per section,
|
||||
--tm-status per person) over the shell's palette. */
|
||||
|
||||
[data-module="pco-plan"] .tm-wrap {
|
||||
--pp-size: 14px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--pp-size);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-head {
|
||||
flex: none;
|
||||
padding: 0.6em 0.85em 0.5em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .tm-head[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .tm-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-sub {
|
||||
margin-top: 0.15em;
|
||||
font-size: 0.82em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-sub[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .tm-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 0.6em;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* ── a team ── */
|
||||
|
||||
[data-module="pco-plan"] .tm-team { --tm-team: var(--accent); }
|
||||
|
||||
/* The header band runs edge to edge, like the Plan tile's section headers. */
|
||||
[data-module="pco-plan"] .tm-team-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
padding: 0.45em 0.9em 0.4em;
|
||||
margin-top: 0.7em;
|
||||
background: color-mix(in srgb, var(--tm-team) 22%, transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .tm-team:first-child .tm-team-head { margin-top: 0; }
|
||||
[data-module="pco-plan"] .header-gradient .tm-team-head {
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--tm-team) 30%, transparent),
|
||||
color-mix(in srgb, var(--tm-team) 6%, transparent) 70%,
|
||||
transparent);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-team-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-team-count {
|
||||
flex: none;
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-people {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
|
||||
gap: 0.15em 0.5em;
|
||||
padding: 0.35em 0.6em 0.2em;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-people.is-fixed-cols {
|
||||
grid-template-columns: repeat(var(--tm-cols, 1), minmax(0, 1fr));
|
||||
}
|
||||
[data-module="pco-plan"] .tm-people.is-sub { padding-top: 0.1em; padding-bottom: 0.1em; }
|
||||
|
||||
/* Grouped by position: headings span, each position's people form their own grid. */
|
||||
[data-module="pco-plan"] .tm-groups { padding-bottom: 0.2em; }
|
||||
|
||||
[data-module="pco-plan"] .tm-position-head {
|
||||
padding: 0.4em 0.9em 0;
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-team-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0.2em 0.3em 0.4em;
|
||||
font-size: 0.85em;
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── one person ── */
|
||||
|
||||
[data-module="pco-plan"] .tm-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
min-width: 0;
|
||||
padding: 0.3em 0.4em;
|
||||
border-radius: 0.45em;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-person:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||
[data-module="pco-plan"] .tint-rows .tm-person { background: color-mix(in srgb, var(--tm-team) 8%, transparent); }
|
||||
[data-module="pco-plan"] .tint-rows .tm-person:hover { background: color-mix(in srgb, var(--tm-team) 16%, transparent); }
|
||||
[data-module="pco-plan"] .tm-person.is-declined { opacity: 0.55; }
|
||||
|
||||
[data-module="pco-plan"] .tm-avatar {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 2.1em;
|
||||
height: 2.1em;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--tm-team) 28%, var(--panel));
|
||||
color: var(--text);
|
||||
font-size: 0.78em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
/* The photo covers the initials; if it fails to load the script drops it. */
|
||||
[data-module="pco-plan"] .tm-avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-person-main { flex: 1; min-width: 0; }
|
||||
|
||||
[data-module="pco-plan"] .tm-name {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-person.is-declined .tm-name { text-decoration: line-through; }
|
||||
|
||||
[data-module="pco-plan"] .tm-position {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-note {
|
||||
margin-top: 0.1em;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.3;
|
||||
color: color-mix(in srgb, var(--text) 75%, var(--muted));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .tm-status {
|
||||
flex: none;
|
||||
width: 0.55em;
|
||||
height: 0.55em;
|
||||
border-radius: 999px;
|
||||
background: var(--tm-status, var(--muted));
|
||||
box-shadow: 0 0 0 0.18em color-mix(in srgb, var(--tm-status, var(--muted)) 18%, transparent);
|
||||
}
|
||||
|
||||
/* ── empty / degraded ── */
|
||||
|
||||
[data-module="pco-plan"] .tm-empty {
|
||||
padding: 12% 1.2em 1em;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="pco-plan"] .tm-empty-title { font-size: 1.1em; font-weight: 700; color: var(--text); }
|
||||
[data-module="pco-plan"] .tm-empty-hint { margin-top: 0.4em; font-size: 0.9em; line-height: 1.4; }
|
||||
[data-module="pco-plan"] .tm-empty-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.9em;
|
||||
padding: 0.35em 0.9em;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
[data-module="pco-plan"] .tm-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
|
||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/* PCO Plan — the Teams tile.
|
||||
|
||||
Who is scheduled on the current plan, by team and position, from the same
|
||||
server state the Plan tile draws (state.teams). Everything shown is this
|
||||
tile's own choice (gear menu): which teams and positions, what each row
|
||||
carries, and the colours. Photos come from ProdDash's own avatar route —
|
||||
the browser never talks to Planning Center. */
|
||||
|
||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||
|
||||
const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
|
||||
|
||||
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
|
||||
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
|
||||
|
||||
export default function createTeams({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let feedOffline = false;
|
||||
let stream = null;
|
||||
let lastKey = '';
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="tm-wrap">
|
||||
<header class="tm-head">
|
||||
<div class="tm-title"></div>
|
||||
<div class="tm-sub"></div>
|
||||
</header>
|
||||
<div class="tm-body"></div>
|
||||
</div>`;
|
||||
|
||||
const wrap = root.querySelector('.tm-wrap');
|
||||
const headEl = root.querySelector('.tm-head');
|
||||
const titleEl = root.querySelector('.tm-title');
|
||||
const subEl = root.querySelector('.tm-sub');
|
||||
const bodyEl = root.querySelector('.tm-body');
|
||||
|
||||
const cfg = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||
|
||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||
|
||||
const refreshBtn = moduleApi.header.addButton({
|
||||
icon: REFRESH_SVG,
|
||||
title: 'Re-read from Planning Center',
|
||||
onClick() {
|
||||
refreshBtn.classList.add('active');
|
||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||
.catch(() => { /* the stream reports the outcome */ })
|
||||
.finally(() => refreshBtn.classList.remove('active'));
|
||||
},
|
||||
});
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────── */
|
||||
|
||||
function colorOf(value, fallback) {
|
||||
const s = String(value || '').trim();
|
||||
return /^#[0-9a-fA-F]{3,8}$/.test(s) ? s : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's colour when "Tint teams" is on. Taken from the team's position
|
||||
* in the plan, not a hash of its name: neighbouring teams then always look
|
||||
* different, which is the whole point of tinting them.
|
||||
*/
|
||||
function teamColor(index) {
|
||||
if (!cfg().tintTeams) return colorOf(cfg().teamColor, '#2ee59a');
|
||||
return `hsl(${TEAM_HUES[index % TEAM_HUES.length]} 70% 55%)`;
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
if (status === 'confirmed') return colorOf(cfg().confirmedColor, '#2ee59a');
|
||||
if (status === 'declined') return colorOf(cfg().declinedColor, '#f4433c');
|
||||
return colorOf(cfg().unconfirmedColor, '#f0b429');
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { confirmed: 'Confirmed', unconfirmed: 'Unconfirmed', declined: 'Declined' };
|
||||
|
||||
/** "Sarah Miles" → "SM"; one word → its first two letters. */
|
||||
function initialsOf(name) {
|
||||
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return '?';
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/** A saved multiselect list, or null when the tile has never chosen (= all). */
|
||||
function chosen(key) {
|
||||
const v = cfg()[key];
|
||||
return Array.isArray(v) ? v : null;
|
||||
}
|
||||
|
||||
/* ── which teams and people to show ─────────────────────────────── */
|
||||
|
||||
function visibleTeams() {
|
||||
const teams = Array.isArray(state?.teams) ? state.teams : [];
|
||||
const wantTeams = chosen('teams');
|
||||
const out = [];
|
||||
for (const team of teams) {
|
||||
if (wantTeams && !wantTeams.includes(team.id)) continue;
|
||||
const members = team.members.filter((m) => {
|
||||
if (m.status === 'declined' && !on('showDeclined')) return false;
|
||||
if (m.status === 'unconfirmed' && !on('showUnconfirmed')) return false;
|
||||
return true;
|
||||
});
|
||||
if (!members.length && !cfg().showEmptyTeams) continue;
|
||||
out.push({ ...team, members });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── status dot ─────────────────────────────────────────────────── */
|
||||
|
||||
function renderStatus() {
|
||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
||||
if (!state.reachable) {
|
||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||
}
|
||||
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
|
||||
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
|
||||
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
|
||||
}
|
||||
|
||||
/* ── top bar ────────────────────────────────────────────────────── */
|
||||
|
||||
/** The counts describe what this tile shows, not what the plan holds. */
|
||||
function templateValues() {
|
||||
const plan = state?.plan || {};
|
||||
const teams = visibleTeams();
|
||||
const people = teams.reduce((n, t) => n + t.members.length, 0);
|
||||
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
|
||||
return {
|
||||
series: plan.seriesTitle || '',
|
||||
part: plan.title || '',
|
||||
servicetype: state?.serviceType?.name || '',
|
||||
date: plan.dates || '',
|
||||
shortdate: plan.shortDates || plan.dates || '',
|
||||
time: plan.serviceStartsAt
|
||||
? new Date(plan.serviceStartsAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
: '',
|
||||
people: people ? `${people} ${people === 1 ? 'person' : 'people'}` : '',
|
||||
teams: teams.length ? `${teams.length} ${teams.length === 1 ? 'team' : 'teams'}` : '',
|
||||
confirmed: confirmed ? `${confirmed} confirmed` : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "{serviceType} • {date} | {people} on {teams}" → up to two lines. Within
|
||||
* a line • separates segments and a segment whose placeholders all came out
|
||||
* empty is dropped, so a plan with no series never leaves a stray bullet.
|
||||
*/
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2).map((line) => {
|
||||
const kept = [];
|
||||
for (const seg of line.split(/\s*[•·]\s*/)) {
|
||||
let sawVar = false;
|
||||
let allEmpty = true;
|
||||
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
|
||||
sawVar = true;
|
||||
const v = values[name.toLowerCase()];
|
||||
if (v) allEmpty = false;
|
||||
return v || '';
|
||||
}).trim();
|
||||
if (sawVar && allEmpty) continue;
|
||||
if (text) kept.push(text);
|
||||
}
|
||||
return kept.join(' • ');
|
||||
});
|
||||
}
|
||||
|
||||
function renderHead() {
|
||||
const show = on('showTopBar') && state?.plan;
|
||||
headEl.hidden = !show;
|
||||
if (!show) return;
|
||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
|
||||
subEl.textContent = line1 ? line2 || '' : '';
|
||||
subEl.hidden = !subEl.textContent;
|
||||
}
|
||||
|
||||
/* ── the list ───────────────────────────────────────────────────── */
|
||||
|
||||
function emptyMessage() {
|
||||
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
|
||||
if (!state) return { title: 'Connecting…', hint: '' };
|
||||
if (!state.configured) {
|
||||
return {
|
||||
title: 'Planning Center not connected',
|
||||
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
|
||||
link: { href: '/admin', text: 'Open Admin' },
|
||||
};
|
||||
}
|
||||
if (!state.serviceTypeSet) {
|
||||
return {
|
||||
title: 'No service type selected',
|
||||
hint: 'Pick the service type to follow under PCO Plan in Admin.',
|
||||
link: { href: '/admin', text: 'Open Admin' },
|
||||
};
|
||||
}
|
||||
if (!state.reachable && !state.plan) {
|
||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||
}
|
||||
if (!state.plan) {
|
||||
return {
|
||||
title: 'No plan found',
|
||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
|
||||
};
|
||||
}
|
||||
if (!(state.teams || []).length) {
|
||||
return {
|
||||
title: 'Nobody scheduled yet',
|
||||
hint: state.teamsError || 'This plan has no team members in Planning Center.',
|
||||
};
|
||||
}
|
||||
if (!visibleTeams().length) {
|
||||
return { title: 'Nothing to show', hint: 'Every team is hidden by this tile’s settings (gear menu).' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMember(member) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `tm-person is-${member.status}`;
|
||||
|
||||
if (on('showAvatars')) {
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'tm-avatar';
|
||||
avatar.textContent = initialsOf(member.name);
|
||||
if (member.hasPhoto) {
|
||||
const img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.alt = '';
|
||||
img.src = `/api/modules/${moduleApi.id}/team-avatar/${encodeURIComponent(member.id)}`;
|
||||
// No photo, or the CDN is unreachable: the initials underneath stand.
|
||||
img.addEventListener('error', () => img.remove());
|
||||
avatar.appendChild(img);
|
||||
}
|
||||
row.appendChild(avatar);
|
||||
}
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'tm-person-main';
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'tm-name';
|
||||
nameEl.textContent = member.name;
|
||||
main.appendChild(nameEl);
|
||||
|
||||
if (on('showPosition') && member.position && !cfg().groupByPosition) {
|
||||
const pos = document.createElement('div');
|
||||
pos.className = 'tm-position';
|
||||
pos.textContent = member.position;
|
||||
main.appendChild(pos);
|
||||
}
|
||||
if (cfg().showNotes && (member.notes || member.declineReason)) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'tm-note';
|
||||
note.textContent = member.declineReason ? `Declined: ${member.declineReason}` : member.notes;
|
||||
main.appendChild(note);
|
||||
}
|
||||
row.appendChild(main);
|
||||
|
||||
if (on('showStatus')) {
|
||||
const status = document.createElement('div');
|
||||
status.className = 'tm-status';
|
||||
status.style.setProperty('--tm-status', statusColor(member.status));
|
||||
status.title = STATUS_LABEL[member.status] || member.status;
|
||||
row.appendChild(status);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** One team: its header band, then its people (optionally under position sub-headings). */
|
||||
function buildTeam(team, index) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'tm-team';
|
||||
section.style.setProperty('--tm-team', teamColor(index));
|
||||
|
||||
if (on('showTeamHeaders')) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tm-team-head';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tm-team-name';
|
||||
name.textContent = team.name;
|
||||
header.appendChild(name);
|
||||
if (on('showTeamCounts')) {
|
||||
const counts = document.createElement('span');
|
||||
counts.className = 'tm-team-count';
|
||||
const pending = team.counts.unconfirmed;
|
||||
counts.textContent = pending ? `${team.members.length} · ${pending} pending` : String(team.members.length);
|
||||
header.appendChild(counts);
|
||||
}
|
||||
section.appendChild(header);
|
||||
}
|
||||
|
||||
const columns = String(cfg().columns || 'auto');
|
||||
const grouping = Boolean(cfg().groupByPosition) && on('showPosition');
|
||||
// Grouped, the container is a plain block so each position heading spans
|
||||
// the width and only its own people form a grid.
|
||||
const list = document.createElement('div');
|
||||
list.className = grouping ? 'tm-groups' : 'tm-people';
|
||||
if (!grouping && columns !== 'auto') {
|
||||
list.style.setProperty('--tm-cols', columns);
|
||||
list.classList.add('is-fixed-cols');
|
||||
}
|
||||
|
||||
if (grouping) {
|
||||
// Positions keep the order the members already sort in.
|
||||
const groups = new Map();
|
||||
for (const m of team.members) {
|
||||
const key = m.position || '(no position)';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(m);
|
||||
}
|
||||
for (const [position, members] of groups) {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'tm-position-head';
|
||||
label.textContent = position;
|
||||
list.appendChild(label);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'tm-people is-sub';
|
||||
if (columns !== 'auto') sub.style.setProperty('--tm-cols', columns);
|
||||
sub.classList.toggle('is-fixed-cols', columns !== 'auto');
|
||||
for (const m of members) sub.appendChild(buildMember(m));
|
||||
list.appendChild(sub);
|
||||
}
|
||||
} else {
|
||||
for (const m of team.members) list.appendChild(buildMember(m));
|
||||
}
|
||||
|
||||
if (!team.members.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'tm-team-empty';
|
||||
none.textContent = 'Nobody scheduled';
|
||||
list.appendChild(none);
|
||||
}
|
||||
section.appendChild(list);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const empty = emptyMessage();
|
||||
if (empty) {
|
||||
lastKey = '';
|
||||
bodyEl.innerHTML = '';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'tm-empty';
|
||||
const t = document.createElement('div');
|
||||
t.className = 'tm-empty-title';
|
||||
t.textContent = empty.title;
|
||||
box.appendChild(t);
|
||||
if (empty.hint) {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'tm-empty-hint';
|
||||
h.textContent = empty.hint;
|
||||
box.appendChild(h);
|
||||
}
|
||||
if (empty.link) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'tm-empty-link';
|
||||
a.href = empty.link.href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = empty.link.text;
|
||||
box.appendChild(a);
|
||||
}
|
||||
bodyEl.appendChild(box);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild only when the roster or the settings actually changed: the
|
||||
// avatars are <img>s and a needless rebuild makes them flicker.
|
||||
const teams = visibleTeams();
|
||||
const key = JSON.stringify([teams, cfg()]);
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
bodyEl.innerHTML = '';
|
||||
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
|
||||
}
|
||||
|
||||
function render() {
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
|
||||
wrap.classList.toggle('header-gradient', on('headerGradient'));
|
||||
renderStatus();
|
||||
renderHead();
|
||||
renderBody();
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
function accept(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
feedOffline = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() { feedOffline = false; render(); },
|
||||
error() { feedOffline = true; render(); },
|
||||
events: {
|
||||
state(ev) {
|
||||
try {
|
||||
accept(JSON.parse(ev.data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
moduleApi.fetch('/state')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => { if (body && !state) accept(body); })
|
||||
.catch(() => { /* the stream will cover it */ });
|
||||
connect();
|
||||
render();
|
||||
},
|
||||
stop() {
|
||||
stream?.close();
|
||||
stream = null;
|
||||
root.innerHTML = '';
|
||||
},
|
||||
onConfigChange() {
|
||||
// Admin holds the connection only; the server re-inits and re-sends.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Resi Cues — the tile.
|
||||
*
|
||||
* One number matters: how long until the next cue. It is drawn as large as the
|
||||
* tile allows, with the cue's name above it, because on a wall the operator reads
|
||||
* it from across the booth.
|
||||
*
|
||||
* The countdown ticks locally. The server sends the playhead with the instant it
|
||||
* was true (`estMs` at `estAt`) and whether playback is running; this adds the
|
||||
* elapsed time since. That is what keeps the digits moving every second while
|
||||
* Resi itself only reports every few seconds, and it means a slow poll never
|
||||
* shows as a stalled clock.
|
||||
*/
|
||||
|
||||
const TICK_MS = 250; // redraw faster than a second so the digit turns on time
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let stream = null;
|
||||
let ticker = null;
|
||||
let state = null;
|
||||
|
||||
const settings = () => moduleApi.instanceSettings;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function hms(ms) {
|
||||
const total = Math.max(0, Math.round(ms / 1000));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
/** Where the playhead is right now, extrapolated from the last server word. */
|
||||
function playheadMs() {
|
||||
if (!state || state.estMs === null || state.estMs === undefined) return null;
|
||||
return state.estMs + (state.playing ? Date.now() - state.estAt : 0);
|
||||
}
|
||||
|
||||
function render() {
|
||||
root.textContent = '';
|
||||
const set = settings();
|
||||
|
||||
if (!state || state.status === 'error') {
|
||||
const box = el('div', 'resi-empty');
|
||||
box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected'));
|
||||
root.append(box);
|
||||
return;
|
||||
}
|
||||
|
||||
const here = playheadMs();
|
||||
const wrap = el('div', 'resi-wrap');
|
||||
|
||||
if (state.next) {
|
||||
if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT'));
|
||||
if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue'));
|
||||
const remaining = here === null ? null : state.next.ms - here;
|
||||
const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining));
|
||||
if (remaining !== null && remaining <= 30000) count.classList.add('is-soon');
|
||||
if (!state.playing) count.classList.add('is-held');
|
||||
wrap.append(count);
|
||||
} else {
|
||||
wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING'));
|
||||
wrap.append(el('div', 'resi-cue', state.event || state.decoder || ''));
|
||||
}
|
||||
|
||||
const foot = el('div', 'resi-foot');
|
||||
if (set.showPrevious !== false && state.prev) {
|
||||
foot.append(el('span', 'resi-foot-item',
|
||||
`after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`));
|
||||
}
|
||||
if (set.showPosition !== false && here !== null) {
|
||||
foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`));
|
||||
}
|
||||
if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused'));
|
||||
if (foot.childElementCount) wrap.append(foot);
|
||||
|
||||
if (set.showCueList && state.cues && state.cues.length) {
|
||||
const list = el('ul', 'resi-list');
|
||||
for (const cue of state.cues) {
|
||||
const row = el('li', 'resi-list-row');
|
||||
if (here !== null && cue.ms <= here) row.classList.add('is-past');
|
||||
if (state.next && cue.ms === state.next.ms) row.classList.add('is-next');
|
||||
row.append(el('span', 'resi-list-name', cue.name));
|
||||
row.append(el('span', 'resi-list-at', hms(cue.ms)));
|
||||
list.append(row);
|
||||
}
|
||||
wrap.append(list);
|
||||
}
|
||||
|
||||
root.append(wrap);
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server');
|
||||
if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected');
|
||||
if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`);
|
||||
const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : '';
|
||||
return moduleApi.setStatus('ok', `${state.decoder}${cadence}`);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting');
|
||||
stream = moduleApi.sse('/stream', {
|
||||
state: (data) => {
|
||||
try {
|
||||
state = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
applyStatus();
|
||||
render();
|
||||
},
|
||||
error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'),
|
||||
});
|
||||
ticker = setInterval(render, TICK_MS);
|
||||
render();
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (stream) stream.close();
|
||||
if (ticker) clearInterval(ticker);
|
||||
stream = null;
|
||||
ticker = null;
|
||||
root.textContent = '';
|
||||
},
|
||||
|
||||
onConfigChange() {
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"id": "resi-cues",
|
||||
"name": "Resi Cues",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||
"provides": ["timers"],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 4, "h": 3 },
|
||||
"configGroups": {
|
||||
"Resi account": {
|
||||
"help": "The Resi Studio login the board reads with. Cue points and the decoder's playhead both come from Resi's cloud, so nothing is installed at the decoder. Worth using a dedicated low-privilege Resi user rather than a personal one."
|
||||
},
|
||||
"Decoder": {
|
||||
"help": "Which decoder to follow. The list is read from the account once the login above is saved."
|
||||
},
|
||||
"Timing": {
|
||||
"collapsed": true,
|
||||
"help": "Resi refreshes a decoder's position only every few seconds, on an irregular cadence, and each reading is already slightly stale. The board estimates between readings rather than repeating them, so these only change how often it asks — not how accurate it is."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"label": "Resi login",
|
||||
"group": "Resi account",
|
||||
"default": "",
|
||||
"help": "The email address used to sign in to studio.resi.io."
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"group": "Resi account",
|
||||
"default": ""
|
||||
},
|
||||
"player": {
|
||||
"type": "select",
|
||||
"label": "Decoder",
|
||||
"group": "Decoder",
|
||||
"default": "",
|
||||
"optionsRoute": "/decoders",
|
||||
"help": "Leave unset to follow the only decoder on the account."
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
"label": "Position poll (s)",
|
||||
"group": "Timing",
|
||||
"default": 1,
|
||||
"help": "How often Resi is asked where the playhead is. A faster poll notices a fired cue sooner; it does not make the countdown smoother, which is handled by estimating between readings."
|
||||
},
|
||||
"cueRefreshSeconds": {
|
||||
"type": "number",
|
||||
"label": "Cue list refresh (s)",
|
||||
"group": "Timing",
|
||||
"default": 5,
|
||||
"help": "How often the cue list is re-read. It is also re-read immediately whenever the playhead jumps, so a cue fired at the host shows up without waiting for this."
|
||||
},
|
||||
"positionTrimMs": {
|
||||
"type": "number",
|
||||
"label": "Position trim (ms)",
|
||||
"group": "Timing",
|
||||
"default": 0,
|
||||
"help": "Leave at zero. Only needed if the whole chain reads consistently behind; a positive value pushes the estimate forward."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showCueName": {
|
||||
"type": "switch",
|
||||
"label": "Cue name",
|
||||
"default": true
|
||||
},
|
||||
"showPrevious": {
|
||||
"type": "switch",
|
||||
"label": "Previous cue",
|
||||
"default": true
|
||||
},
|
||||
"showPosition": {
|
||||
"type": "switch",
|
||||
"label": "Playhead",
|
||||
"default": true
|
||||
},
|
||||
"showCueList": {
|
||||
"type": "switch",
|
||||
"label": "Cue list",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/* Resi Cues — talking to Resi, and working out where the decoder actually is.
|
||||
*
|
||||
* Two things live here, both free of the shell so they can be tested on their own
|
||||
* (`resi.test.js`): a thin client for the Resi Central API, and the playhead
|
||||
* estimator that turns its coarse, late readings into a usable position.
|
||||
*
|
||||
* The API is the undocumented one behind studio.resi.io. It is not supported by
|
||||
* Resi and can change without notice; that is the price of there being no public
|
||||
* way to read cue points at all.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const RESI = 'https://central.resi.io';
|
||||
|
||||
/* ── time ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */
|
||||
function positionToMs(position) {
|
||||
if (typeof position !== 'string') return null;
|
||||
const parts = position.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const hh = Number(parts[0]);
|
||||
const mm = Number(parts[1]);
|
||||
const ss = Number(parts[2]);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null;
|
||||
return Math.round((hh * 3600 + mm * 60 + ss) * 1000);
|
||||
}
|
||||
|
||||
/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */
|
||||
function msToPosition(total) {
|
||||
const t = Math.max(0, Math.floor(total));
|
||||
const pad = (n, w) => String(n).padStart(w, '0');
|
||||
return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}`
|
||||
+ `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`;
|
||||
}
|
||||
|
||||
/* ── the playhead estimator ────────────────────────────────────────────── */
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||
|
||||
/**
|
||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||
* cadence, and each reading is already a little stale when it appears. Shown raw,
|
||||
* a countdown built on it sits seconds behind and moves in lurches.
|
||||
*
|
||||
* So every reading is kept as *media time minus wall time*. While playback runs at
|
||||
* 1x that difference is constant, and the LARGEST recent value is the reading that
|
||||
* reached us with the least delay — the one to trust. The estimate is then simply
|
||||
* "now plus that offset", which advances smoothly by itself and cannot be dragged
|
||||
* backwards by a slow round trip.
|
||||
*
|
||||
* `now` is injected so the tests can drive a fake clock.
|
||||
*/
|
||||
function createPlayhead(options) {
|
||||
const opts = options || {};
|
||||
const trimMs = Number(opts.trimMs) || 0;
|
||||
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||
|
||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
||||
let spreads = []; // how far apart honest readings sit: this link's noise
|
||||
let gaps = []; // how long between the playhead actually moving
|
||||
let base; // the trusted offset
|
||||
let lastMedia;
|
||||
let lastNewAt;
|
||||
let lastEst;
|
||||
let seeked = false;
|
||||
|
||||
function note(mediaMs) {
|
||||
if (!Number.isFinite(mediaMs)) return;
|
||||
const t = now();
|
||||
if (mediaMs !== lastMedia) {
|
||||
const off = mediaMs - t;
|
||||
// Anything inside the measured noise floor is noise; only a move clearly
|
||||
// beyond it is a real seek. Measured rather than assumed, and remembered
|
||||
// across a reset so the first reading after one is not judged blind.
|
||||
const noise = spreads.length ? Math.max.apply(null, spreads) : 0;
|
||||
const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000);
|
||||
if (base !== undefined) {
|
||||
const delta = off - base;
|
||||
if (Math.abs(delta) > threshold) {
|
||||
offsets = []; // the window describes where we used to be
|
||||
seeked = true;
|
||||
// Only abandon the no-going-backwards guard for a move big enough that
|
||||
// snapping is obviously right; a marginal call must not make the
|
||||
// displayed playhead lurch backwards.
|
||||
if (Math.abs(delta) > 10000) lastEst = undefined;
|
||||
} else if (delta < 0) {
|
||||
spreads = spreads.concat(-delta).slice(-20);
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) {
|
||||
const gap = t - lastNewAt;
|
||||
// The gap either side of a pause is not Resi's cadence — recording it
|
||||
// would inflate the estimate and make the next pause slow to notice.
|
||||
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
}
|
||||
}
|
||||
offsets = offsets.concat({ at: t, off });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
}
|
||||
const kept = offsets.filter((o) => t - o.at <= WINDOW_MS);
|
||||
// Always keep the newest reading even once it ages out. If the window empties
|
||||
// during a long pause there is nothing left to compare against, and the resume
|
||||
// reads as an ordinary reading instead of the discontinuity it is.
|
||||
offsets = kept.length ? kept : offsets.slice(-1);
|
||||
base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined;
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 };
|
||||
const t = now();
|
||||
const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
// Only call it paused once the playhead has been still for clearly longer than
|
||||
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
||||
// countdown that freezes mid-service is the worse failure of the two.
|
||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
}
|
||||
let est = t + base + trimMs;
|
||||
if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max
|
||||
lastEst = est;
|
||||
return { estMs: est, playing: true, cadenceMs };
|
||||
}
|
||||
|
||||
/** True once per detected discontinuity — the caller re-reads the cue list. */
|
||||
function takeSeek() {
|
||||
const was = seeked;
|
||||
seeked = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
return { note, read, takeSeek };
|
||||
}
|
||||
|
||||
/* ── the Resi Central API ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Login sets a `livingas1` cookie which doubles as the bearer token, sent as
|
||||
* `Authorization: X-Bearer <cookie>`. Everything else is a plain GET.
|
||||
*/
|
||||
function createResiClient(options) {
|
||||
const opts = options || {};
|
||||
const doFetch = opts.fetch || globalThis.fetch;
|
||||
let cookieHeader = '';
|
||||
let token = '';
|
||||
let loggedInAt = 0;
|
||||
let customerId = '';
|
||||
let cacheBust = true;
|
||||
|
||||
function headers() {
|
||||
const h = { Accept: 'application/json' };
|
||||
if (cookieHeader) h.Cookie = cookieHeader;
|
||||
if (token) h.Authorization = `X-Bearer ${token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
for (const raw of setCookies) {
|
||||
const first = String(raw).split(';')[0];
|
||||
const eq = first.indexOf('=');
|
||||
if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1);
|
||||
}
|
||||
token = jar.livingas1 || '';
|
||||
if (!token) throw new Error('Resi login returned no session cookie');
|
||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||
loggedInAt = Date.now();
|
||||
const me = await get('/api_v2.svc/users/me');
|
||||
customerId = (me && me.customerId) || '';
|
||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||
return me;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await login();
|
||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
||||
return retry.json();
|
||||
}
|
||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function venues() {
|
||||
await ensureSession();
|
||||
return (await get(`/api/v3/customers/${customerId}/venues`)) || [];
|
||||
}
|
||||
|
||||
/** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh
|
||||
* one — and if the service objects to the parameter, stop sending it. */
|
||||
async function players(venueUuid) {
|
||||
await ensureSession();
|
||||
const path = `/api_v2.svc/users/${venueUuid}/players`;
|
||||
if (cacheBust) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} catch (err) {
|
||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
||||
cacheBust = false;
|
||||
}
|
||||
}
|
||||
return (await get(path)) || [];
|
||||
}
|
||||
|
||||
async function event(eventId) {
|
||||
await ensureSession();
|
||||
return get(`/api/v3/customers/${customerId}/events/${eventId}`);
|
||||
}
|
||||
|
||||
async function cues(profileId, eventId) {
|
||||
await ensureSession();
|
||||
const raw = (await get(
|
||||
`/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || [];
|
||||
return raw
|
||||
.map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) }))
|
||||
.filter((c) => c.ms !== null)
|
||||
.sort((a, b) => a.ms - b.ms);
|
||||
}
|
||||
|
||||
return {
|
||||
login,
|
||||
ensureSession,
|
||||
venues,
|
||||
players,
|
||||
event,
|
||||
cues,
|
||||
get customerId() { return customerId; },
|
||||
};
|
||||
}
|
||||
|
||||
/** The cue after `estMs`, and the one before it. */
|
||||
function surroundingCues(cues, estMs) {
|
||||
let prev = null;
|
||||
let next = null;
|
||||
for (const c of cues || []) {
|
||||
if (c.ms > estMs) { next = c; break; }
|
||||
prev = c;
|
||||
}
|
||||
return { prev, next };
|
||||
}
|
||||
|
||||
module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI };
|
||||
@@ -0,0 +1,210 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit tests for the Resi playhead estimator.
|
||||
*
|
||||
* node modules/resi-cues/resi.test.js
|
||||
* node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure.
|
||||
*
|
||||
* The estimator's whole job is to be right about a feed that is coarse, late and
|
||||
* irregular, so the fixture is a simulated Resi rather than a list of values: a
|
||||
* decoder playing at 1x, and an API that republishes its position every so often,
|
||||
* already stale, over a round trip that varies. Nine profiles span what the real
|
||||
* one has been seen to do. Feeding it a kind, punctual API proves nothing —
|
||||
* against one, three separate breakages of this code go unnoticed.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
|
||||
/* ── the simulated world ───────────────────────────────────────────────── */
|
||||
|
||||
function makeWorld({ cadence, lag, jitter, net }) {
|
||||
let seed = 12345;
|
||||
const rnd = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
const w = {
|
||||
t: 1700000000000,
|
||||
media: 8 * 60000 + 48000,
|
||||
playing: true,
|
||||
published: 0,
|
||||
lastPublish: 0,
|
||||
nextCadence: cadence,
|
||||
};
|
||||
w.published = w.media - lag;
|
||||
w.lastPublish = w.t;
|
||||
w.advance = (ms) => {
|
||||
for (let i = 0; i < ms; i += 100) {
|
||||
w.t += 100;
|
||||
if (w.playing) w.media += 100;
|
||||
if (w.t - w.lastPublish >= w.nextCadence) {
|
||||
w.lastPublish = w.t;
|
||||
w.published = w.media - lag;
|
||||
w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter));
|
||||
}
|
||||
}
|
||||
};
|
||||
// A poll reads what Resi had when the request left, and the round trip costs time.
|
||||
w.poll = () => {
|
||||
const body = w.published;
|
||||
if (net) w.advance(100 + Math.round(rnd() * net));
|
||||
return body;
|
||||
};
|
||||
return w;
|
||||
}
|
||||
|
||||
const CUES = [
|
||||
{ name: 'Welcome', ms: 5 * 60000 },
|
||||
{ name: 'Campus Host', ms: 12 * 60000 + 30000 },
|
||||
{ name: 'Message', ms: 41 * 60000 },
|
||||
{ name: 'Closing', ms: 45 * 60000 },
|
||||
];
|
||||
|
||||
function runProfile(cadence, lag, jitter, net) {
|
||||
const w = makeWorld({ cadence, lag, jitter, net });
|
||||
const head = createPlayhead({ now: () => w.t });
|
||||
const tol = lag + 1200 + jitter + net * 0.35;
|
||||
const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`;
|
||||
|
||||
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
|
||||
const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; };
|
||||
|
||||
test(`${label}: locks on, and beats the raw reading it is built from`, () => {
|
||||
seconds(40);
|
||||
let worstEst = 0;
|
||||
let worstRaw = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const { estMs } = second();
|
||||
worstEst = Math.max(worstEst, w.media - estMs);
|
||||
worstRaw = Math.max(worstRaw, w.media - w.published);
|
||||
}
|
||||
assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`);
|
||||
if (cadence >= 2000) {
|
||||
assert.ok(worstRaw >= cadence * 0.4,
|
||||
`fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`);
|
||||
}
|
||||
assert.ok(head.read().playing, 'should read as playing');
|
||||
});
|
||||
|
||||
test(`${label}: ticks every second and never runs backwards`, () => {
|
||||
let prev = head.read().estMs;
|
||||
let stalls = 0;
|
||||
let backwards = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const { estMs } = second();
|
||||
if (estMs === prev) stalls++;
|
||||
if (estMs < prev) backwards++;
|
||||
prev = estMs;
|
||||
}
|
||||
assert.strictEqual(stalls, 0, 'the playhead stalled');
|
||||
assert.strictEqual(backwards, 0, 'the playhead went backwards');
|
||||
});
|
||||
|
||||
test(`${label}: follows a fired cue, and rolls over the one it passes`, () => {
|
||||
w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
|
||||
let reported = false;
|
||||
for (let i = 0; i < 60 && !reported; i++) {
|
||||
second();
|
||||
reported = Math.abs((head.read().estMs || 0) - w.media) < 8000;
|
||||
}
|
||||
assert.ok(reported, 'the jump was never picked up');
|
||||
assert.ok(head.takeSeek(), 'the jump was not flagged as a seek');
|
||||
const { estMs } = head.read();
|
||||
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2,
|
||||
`resynced ${Math.round(w.media - estMs)}ms off`);
|
||||
assert.ok(w.media - estMs >= -400, 'overshot the new playhead');
|
||||
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message');
|
||||
const after = seconds(15);
|
||||
assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing');
|
||||
assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message');
|
||||
});
|
||||
|
||||
test(`${label}: follows a seek backwards`, () => {
|
||||
w.media = 6 * 60000;
|
||||
for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second();
|
||||
const { estMs } = head.read();
|
||||
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back');
|
||||
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host');
|
||||
});
|
||||
|
||||
test(`${label}: freezes on a real pause and re-locks on resume`, () => {
|
||||
w.playing = false;
|
||||
const stoppedAt = w.media;
|
||||
const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000))
|
||||
+ cadence + jitter + 6000) / 1000);
|
||||
let r = seconds(wait);
|
||||
assert.strictEqual(r.playing, false, 'did not notice the pause');
|
||||
const held = r.estMs;
|
||||
r = seconds(20);
|
||||
assert.strictEqual(r.estMs, held, 'kept counting while paused');
|
||||
|
||||
w.playing = true;
|
||||
r = seconds(25);
|
||||
assert.strictEqual(r.playing, true, 'did not notice the resume');
|
||||
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
|
||||
assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000,
|
||||
'the frozen position crept instead of stopping');
|
||||
});
|
||||
|
||||
test(`${label}: survives a pause longer than the offset window`, () => {
|
||||
w.playing = false;
|
||||
let r = seconds(150);
|
||||
assert.strictEqual(r.playing, false, 'should still read as paused');
|
||||
assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely');
|
||||
w.playing = true;
|
||||
head.takeSeek();
|
||||
r = seconds(30);
|
||||
assert.strictEqual(r.playing, true, 'did not recover');
|
||||
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
|
||||
assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek');
|
||||
});
|
||||
}
|
||||
|
||||
/* ── the plain bits ────────────────────────────────────────────────────── */
|
||||
|
||||
test('positionToMs reads Resi positions, and rejects anything else', () => {
|
||||
assert.strictEqual(positionToMs('00:00:00.000'), 0);
|
||||
assert.strictEqual(positionToMs('01:02:03.500'), 3723500);
|
||||
assert.strictEqual(positionToMs('00:00:01'), 1000);
|
||||
assert.strictEqual(positionToMs(''), null);
|
||||
assert.strictEqual(positionToMs('garbage'), null);
|
||||
assert.strictEqual(positionToMs(null), null);
|
||||
assert.strictEqual(positionToMs(undefined), null);
|
||||
});
|
||||
|
||||
test('msToPosition round-trips', () => {
|
||||
for (const ms of [0, 1500, 3723500, 36000000]) {
|
||||
assert.strictEqual(positionToMs(msToPosition(ms)), ms);
|
||||
}
|
||||
});
|
||||
|
||||
test('surroundingCues puts a cue you are exactly on behind you', () => {
|
||||
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message');
|
||||
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host');
|
||||
assert.strictEqual(surroundingCues(CUES, 0).prev, null);
|
||||
assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null);
|
||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
||||
});
|
||||
|
||||
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||
|
||||
const argv = process.argv.slice(2).map(Number);
|
||||
const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [
|
||||
[5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800],
|
||||
[10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000],
|
||||
[12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000],
|
||||
];
|
||||
for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net);
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -0,0 +1,356 @@
|
||||
/* Resi Cues — server entry.
|
||||
*
|
||||
* Follows one Resi decoder and works out how long until its next cue point, then
|
||||
* offers that two ways:
|
||||
*
|
||||
* GET /state → snapshot for this module's tile
|
||||
* GET /stream → SSE, `state` events on every change
|
||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||
* GET /decoders → optionsRoute for the admin page's decoder picker
|
||||
*
|
||||
* Both the cue list and the playhead come from Resi's cloud rather than the
|
||||
* decoder: the decoder reports its position upstream and central.resi.io hands it
|
||||
* back. Nothing is installed at the decoder end, and nothing here talks to the
|
||||
* decoder directly.
|
||||
*
|
||||
* Why the playhead is estimated rather than repeated is explained in resi.js.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { createResiClient, createPlayhead, surroundingCues, positionToMs } = require('./resi');
|
||||
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function sseHead(res) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
}
|
||||
|
||||
function writeAll(set, chunk) {
|
||||
for (const res of set) {
|
||||
try {
|
||||
res.write(chunk);
|
||||
} catch {
|
||||
set.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the tile and the Timers module see before anything has been configured. */
|
||||
function emptyState(message) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: message || 'Not configured',
|
||||
decoder: '',
|
||||
event: '',
|
||||
playing: false,
|
||||
estMs: null,
|
||||
estAt: Date.now(),
|
||||
cadenceMs: 0,
|
||||
next: null,
|
||||
prev: null,
|
||||
cues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTimers(now) {
|
||||
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers: [] };
|
||||
}
|
||||
|
||||
let current = null; // the live handle, shared with routes()
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const username = String(config.username || '').trim();
|
||||
const password = String(config.password || '');
|
||||
const wanted = String(config.player || '').trim();
|
||||
const pollMs = Math.max(250, Number(config.pollSeconds || 1) * 1000);
|
||||
const cueRefreshMs = Math.max(1000, Number(config.cueRefreshSeconds || 5) * 1000);
|
||||
|
||||
const stateStreams = new Set();
|
||||
const timerStreams = new Set();
|
||||
let state = emptyState(username && password ? 'Connecting' : 'Enter the Resi login in admin');
|
||||
let timer = null;
|
||||
let heartbeat = null;
|
||||
let stopped = false;
|
||||
|
||||
const resi = createResiClient({ username, password });
|
||||
const head = createPlayhead({ trimMs: Number(config.positionTrimMs || 0) });
|
||||
|
||||
let venueUuid = '';
|
||||
let hardwareId = '';
|
||||
let decoderName = '';
|
||||
let eventId = '';
|
||||
let profileId = '';
|
||||
let eventName = '';
|
||||
let cues = [];
|
||||
let lastCueLoad = 0;
|
||||
let inFlight = false;
|
||||
|
||||
/* — what the two consumers see — */
|
||||
|
||||
function publishState(next) {
|
||||
const before = JSON.stringify(state);
|
||||
state = next;
|
||||
if (JSON.stringify(state) === before) return;
|
||||
writeAll(stateStreams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Timers module extrapolates from `startedAt` and its own clock, so this
|
||||
* only has to change when a timer changes *structurally* — a new cue, a pause,
|
||||
* a seek. Broadcasting every poll would make every dashboard repaint at 1 Hz
|
||||
* for nothing.
|
||||
*/
|
||||
function timersSignature(snap) {
|
||||
return JSON.stringify(snap.timers.map(
|
||||
(t) => [t.id, t.label, t.state, t.startedAt, t.targetMs, t.status && t.status.text]));
|
||||
}
|
||||
|
||||
let lastTimerSig = '';
|
||||
function publishTimers() {
|
||||
const snap = timersSnapshot(Date.now());
|
||||
const sig = timersSignature(snap);
|
||||
if (sig === lastTimerSig) return;
|
||||
lastTimerSig = sig;
|
||||
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
}
|
||||
|
||||
function timersSnapshot(now) {
|
||||
const timers = [];
|
||||
if (state.estMs !== null && state.estMs !== undefined) {
|
||||
const est = state.estMs + (state.playing ? now - state.estAt : 0);
|
||||
if (state.next) {
|
||||
const remainingMs = Math.max(0, state.next.ms - est);
|
||||
// The countdown "runs" from the cue before it, which is what gives the
|
||||
// consumer something to colour progress against.
|
||||
const from = state.prev ? state.prev.ms : 0;
|
||||
timers.push({
|
||||
id: 'next-cue',
|
||||
label: state.next.name || 'Next cue',
|
||||
kind: 'countdown',
|
||||
state: state.playing ? 'running' : 'paused',
|
||||
startedAt: now - Math.max(0, est - from),
|
||||
elapsedMs: null,
|
||||
remainingMs,
|
||||
targetMs: Math.max(1000, state.next.ms - from),
|
||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
||||
detail: state.event || '',
|
||||
});
|
||||
}
|
||||
timers.push({
|
||||
id: 'playhead',
|
||||
label: 'Playhead',
|
||||
kind: 'elapsed',
|
||||
state: state.playing ? 'running' : 'paused',
|
||||
startedAt: now - Math.max(0, est),
|
||||
elapsedMs: Math.max(0, est),
|
||||
remainingMs: null,
|
||||
targetMs: null,
|
||||
status: { text: state.decoder || '', tone: state.playing ? 'ok' : 'muted' },
|
||||
detail: state.prev ? `After ${state.prev.name}` : '',
|
||||
});
|
||||
}
|
||||
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers };
|
||||
}
|
||||
|
||||
/* — finding the decoder — */
|
||||
|
||||
async function locate() {
|
||||
const venues = await resi.venues();
|
||||
for (const v of venues) {
|
||||
const players = await resi.players(v.uuid);
|
||||
for (const p of players) {
|
||||
if (wanted ? p.playerHardwareId === wanted : true) {
|
||||
venueUuid = v.uuid;
|
||||
hardwareId = p.playerHardwareId;
|
||||
decoderName = `${v.userName || v.name || 'Venue'} — ${p.userName || hardwareId}`;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* — the loop — */
|
||||
|
||||
async function tick() {
|
||||
if (inFlight || stopped) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
if (!hardwareId && !(await locate())) {
|
||||
publishState(emptyState(wanted ? 'That decoder is not on this account' : 'No decoders found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const players = await resi.players(venueUuid);
|
||||
const me = players.find((p) => p.playerHardwareId === hardwareId);
|
||||
if (!me) {
|
||||
publishState({ ...emptyState('Decoder not listed'), decoder: decoderName });
|
||||
return;
|
||||
}
|
||||
|
||||
head.note(positionToMs(me.position));
|
||||
|
||||
if (me.eventId && me.eventId !== eventId) {
|
||||
eventId = me.eventId;
|
||||
const ev = (await resi.event(eventId)) || {};
|
||||
profileId = ev.eventProfileId || '';
|
||||
eventName = ev.name || '';
|
||||
cues = profileId ? await resi.cues(profileId, eventId) : [];
|
||||
lastCueLoad = Date.now();
|
||||
log(`event ${eventName || eventId} — ${cues.length} cue(s)`);
|
||||
} else if (!me.eventId) {
|
||||
eventId = '';
|
||||
profileId = '';
|
||||
eventName = '';
|
||||
cues = [];
|
||||
}
|
||||
|
||||
const seeked = head.takeSeek();
|
||||
if (profileId && eventId && (seeked || Date.now() - lastCueLoad >= cueRefreshMs)) {
|
||||
lastCueLoad = Date.now();
|
||||
const fresh = await resi.cues(profileId, eventId);
|
||||
if (JSON.stringify(fresh) !== JSON.stringify(cues)) {
|
||||
log(`cue list changed — ${fresh.length} cue(s)`);
|
||||
cues = fresh;
|
||||
}
|
||||
}
|
||||
|
||||
const { estMs, playing, cadenceMs } = head.read();
|
||||
const { prev, next } = surroundingCues(cues, estMs === null ? -1 : estMs);
|
||||
publishState({
|
||||
status: 'ok',
|
||||
message: '',
|
||||
decoder: decoderName,
|
||||
event: eventName,
|
||||
playing,
|
||||
estMs,
|
||||
estAt: Date.now(),
|
||||
cadenceMs,
|
||||
next,
|
||||
prev,
|
||||
cues,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = (err && err.message) || String(err);
|
||||
log(`poll failed: ${message}`);
|
||||
publishState({ ...state, status: 'error', message });
|
||||
} finally {
|
||||
inFlight = false;
|
||||
publishTimers();
|
||||
}
|
||||
}
|
||||
|
||||
if (username && password) {
|
||||
timer = setInterval(() => { void tick(); }, pollMs);
|
||||
void tick();
|
||||
} else {
|
||||
log('no Resi login configured — idle');
|
||||
}
|
||||
heartbeat = setInterval(() => {
|
||||
writeAll(stateStreams, ': ping\n\n');
|
||||
writeAll(timerStreams, ': ping\n\n');
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
current = {
|
||||
get state() { return state; },
|
||||
timersSnapshot,
|
||||
stateStreams,
|
||||
timerStreams,
|
||||
resi,
|
||||
configured: Boolean(username && password),
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer) clearInterval(timer);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
for (const res of [...stateStreams, ...timerStreams]) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
stateStreams.clear();
|
||||
timerStreams.clear();
|
||||
if (current && current.stop === this.stop) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!username || !password) return { status: 'error', message: 'Enter the Resi login in admin' };
|
||||
if (state.status === 'ok') {
|
||||
return { status: 'ok', message: state.playing ? `Playing — ${state.decoder}` : `Paused — ${state.decoder}` };
|
||||
}
|
||||
if (state.status === 'error') return { status: 'error', message: state.message };
|
||||
return { status: 'connecting', message: state.message };
|
||||
},
|
||||
};
|
||||
return current;
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
json(res, 200, current ? current.state : emptyState('Module restarting'));
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
sseHead(res);
|
||||
const snap = current ? current.state : emptyState('Module restarting');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
if (!current) return res.end();
|
||||
current.stateStreams.add(res);
|
||||
req.on('close', () => current && current.stateStreams.delete(res));
|
||||
},
|
||||
|
||||
'GET /timers': (req, res) => {
|
||||
const now = Date.now();
|
||||
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
|
||||
},
|
||||
|
||||
'GET /timers/stream': (req, res) => {
|
||||
sseHead(res);
|
||||
const now = Date.now();
|
||||
const snap = current ? current.timersSnapshot(now) : emptyTimers(now);
|
||||
res.write(`event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
||||
if (!current) return res.end();
|
||||
current.timerStreams.add(res);
|
||||
req.on('close', () => current && current.timerStreams.delete(res));
|
||||
},
|
||||
|
||||
// The admin page's decoder picker. A live call, because it is how the decoder
|
||||
// gets chosen in the first place — there is nothing cached to answer from.
|
||||
'GET /decoders': async (req, res) => {
|
||||
if (!config.username || !config.password) {
|
||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||
}
|
||||
try {
|
||||
const client = createResiClient({
|
||||
username: String(config.username), password: String(config.password),
|
||||
});
|
||||
const options = [];
|
||||
for (const v of await client.venues()) {
|
||||
for (const p of await client.players(v.uuid)) {
|
||||
options.push({
|
||||
value: p.playerHardwareId,
|
||||
label: `${p.userName || p.playerHardwareId}`,
|
||||
group: v.userName || v.name || 'Venue',
|
||||
});
|
||||
}
|
||||
}
|
||||
json(res, 200, { options });
|
||||
} catch (err) {
|
||||
log(`decoder list failed: ${(err && err.message) || err}`);
|
||||
json(res, 502, { options: [], error: (err && err.message) || String(err) });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Server-entry tests for Resi Cues.
|
||||
*
|
||||
* node modules/resi-cues/server.test.js
|
||||
*
|
||||
* Plain Node, no dependencies; exits non-zero on the first failure. Resi is
|
||||
* replaced with a canned account — a venue, a decoder on an event, four cue
|
||||
* points — so this covers the parts `resi.test.js` cannot: signing in, finding
|
||||
* the decoder, following the event, and the two snapshots other things read
|
||||
* (`/state` for the tile, `/timers` for the Timers module).
|
||||
*
|
||||
* Nothing here reaches the network, and the poll interval is captured rather
|
||||
* than run, so the test drives the loop a tick at a time.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
|
||||
/* ── a canned Resi account ─────────────────────────────────────────────── */
|
||||
|
||||
const CUES = [
|
||||
{ name: 'Welcome', position: '00:05:00.000' },
|
||||
{ name: 'Campus Host', position: '00:12:30.000' },
|
||||
{ name: 'Message', position: '00:41:00.000' },
|
||||
];
|
||||
|
||||
const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 };
|
||||
|
||||
function reply(body, extra) {
|
||||
return Object.assign({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => body,
|
||||
}, extra || {});
|
||||
}
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (path === '/api/v3/login') {
|
||||
world.logins++;
|
||||
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } });
|
||||
}
|
||||
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' });
|
||||
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]);
|
||||
if (path === '/api_v2.svc/users/V1/players') {
|
||||
world.playerCalls++;
|
||||
return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]);
|
||||
}
|
||||
if (path === '/api/v3/customers/CUST/events/EV1') {
|
||||
return reply({ name: 'Sunday 9am', eventProfileId: 'P1' });
|
||||
}
|
||||
if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES);
|
||||
return reply({}, { ok: false, status: 404 });
|
||||
};
|
||||
|
||||
/* ── the shell, as much of it as the module touches ────────────────────── */
|
||||
|
||||
const realSetInterval = globalThis.setInterval;
|
||||
const realClearInterval = globalThis.clearInterval;
|
||||
let tickFn = null;
|
||||
globalThis.setInterval = (fn, ms) => {
|
||||
if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one
|
||||
return { fn, ms };
|
||||
};
|
||||
globalThis.clearInterval = () => {};
|
||||
|
||||
const server = require('./server');
|
||||
const logged = [];
|
||||
const handle = server.init({
|
||||
config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 },
|
||||
log: (m) => logged.push(m),
|
||||
});
|
||||
|
||||
/** Let the poll's promise chain finish; nothing here waits on real time. */
|
||||
async function settle() {
|
||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
function collect(route, req) {
|
||||
const res = {
|
||||
code: 0, headers: null, body: '',
|
||||
writeHead(code, headers) { this.code = code; this.headers = headers; },
|
||||
write(chunk) { this.body += chunk; },
|
||||
end(chunk) { if (chunk) this.body += chunk; },
|
||||
};
|
||||
route(req || { on() {} }, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await settle();
|
||||
|
||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||
|
||||
test('signs in once and finds the configured decoder', () => {
|
||||
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.status, 'ok', state.message);
|
||||
assert.strictEqual(state.decoder, 'Main — Lobby');
|
||||
assert.strictEqual(state.event, 'Sunday 9am');
|
||||
assert.strictEqual(state.cues.length, 3);
|
||||
});
|
||||
|
||||
test('works out the cue either side of the playhead', () => {
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.next.name, 'Campus Host');
|
||||
assert.strictEqual(state.prev.name, 'Welcome');
|
||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
||||
});
|
||||
|
||||
test('/state answers the same snapshot as the stream', () => {
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
||||
});
|
||||
|
||||
test('/stream opens with an event so a new tile paints at once', () => {
|
||||
const res = collect(routes['GET /stream']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
||||
assert.ok(res.body.includes('retry: 3000'), 'should set a retry');
|
||||
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
||||
});
|
||||
|
||||
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.source, 'resi-cues');
|
||||
assert.strictEqual(snap.label, 'Resi');
|
||||
assert.ok(snap.updatedAt > 0);
|
||||
const [next, playhead] = snap.timers;
|
||||
|
||||
assert.strictEqual(next.id, 'next-cue');
|
||||
assert.strictEqual(next.kind, 'countdown');
|
||||
assert.strictEqual(next.state, 'running');
|
||||
assert.strictEqual(next.label, 'Campus Host');
|
||||
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
||||
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||
assert.strictEqual(next.targetMs, 450000);
|
||||
assert.strictEqual(next.status.text, 'Campus Host');
|
||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||
|
||||
assert.strictEqual(playhead.id, 'playhead');
|
||||
assert.strictEqual(playhead.kind, 'elapsed');
|
||||
assert.strictEqual(playhead.remainingMs, null);
|
||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||
});
|
||||
|
||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||
tickFn();
|
||||
await settle();
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.next.name, 'Message');
|
||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||
assert.strictEqual(snap.timers[0].label, 'Message');
|
||||
});
|
||||
|
||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||
world.eventId = '';
|
||||
tickFn();
|
||||
await settle();
|
||||
assert.strictEqual(handle.state.next, null);
|
||||
assert.deepStrictEqual(handle.state.cues, []);
|
||||
assert.strictEqual(handle.state.event, '');
|
||||
});
|
||||
|
||||
test('health speaks about the upstream, not about itself', () => {
|
||||
const h = handle.health();
|
||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
||||
});
|
||||
|
||||
test('an unconfigured module idles instead of hammering Resi', () => {
|
||||
const before = world.logins;
|
||||
const idle = server.init({ config: {}, log: () => {} });
|
||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||
assert.strictEqual(idle.health().status, 'error');
|
||||
assert.ok(/login/i.test(idle.health().message), idle.health().message);
|
||||
idle.stop();
|
||||
});
|
||||
|
||||
test('stop() leaves nothing running', () => {
|
||||
handle.stop();
|
||||
const res = collect(routes['GET /state']);
|
||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||
});
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
globalThis.clearInterval = realClearInterval;
|
||||
console.log(`\n${passed} tests passed`);
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Resi Cues — scoped under its tile via [data-module="resi-cues"], styled with
|
||||
the shell's CSS variables only.
|
||||
|
||||
The countdown is sized in cqmin so it fills whatever the tile is given: the
|
||||
same module is a small strip on a busy dashboard and a wall panel on its own. */
|
||||
|
||||
[data-module="resi-cues"] {
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35em;
|
||||
padding: 2%;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-label {
|
||||
color: var(--muted);
|
||||
font-size: clamp(0.6rem, 5cqmin, 1rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-cue {
|
||||
color: var(--text);
|
||||
font-size: clamp(0.85rem, 11cqmin, 2.6rem);
|
||||
font-weight: 650;
|
||||
line-height: 1.05;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-count {
|
||||
color: var(--accent);
|
||||
font-size: clamp(1.4rem, 30cqmin, 9rem);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-count.is-soon { color: var(--warn); }
|
||||
|
||||
/* Held rather than counting: the digits are still true, they just are not moving. */
|
||||
[data-module="resi-cues"] .resi-count.is-held,
|
||||
[data-module="resi-cues"] .resi-foot-item.is-held {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.2em 0.9em;
|
||||
color: var(--muted);
|
||||
font-size: clamp(0.6rem, 5cqmin, 0.95rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list {
|
||||
list-style: none;
|
||||
margin: 0.4em 0 0;
|
||||
padding: 0.4em 0 0;
|
||||
width: 100%;
|
||||
max-width: 28em;
|
||||
border-top: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
font-size: clamp(0.6rem, 4.5cqmin, 0.95rem);
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
padding: 0.12em 0.2em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list-row.is-past { color: var(--muted); }
|
||||
|
||||
[data-module="resi-cues"] .resi-list-row.is-next {
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-list-at { font-variant-numeric: tabular-nums; }
|
||||
|
||||
[data-module="resi-cues"] .resi-list-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-module="resi-cues"] .resi-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6%;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
@@ -114,19 +114,11 @@ module.exports = {
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
toolMissing: '',
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
// Before anything else: the device picker shells out to this tool, so a
|
||||
// machine that installed the module from the catalog (source only — the
|
||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
||||
// listener would never happen: no tool means no device list, and no
|
||||
// device means no listener.
|
||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
@@ -185,9 +177,6 @@ module.exports = {
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
||||
return { status: 'error', message: state.toolMissing };
|
||||
}
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const quiet = silentMs();
|
||||
@@ -311,13 +300,6 @@ module.exports = {
|
||||
/** Device list for the admin page. Runs the capture tool's --list,
|
||||
which is the only thing that knows what CoreAudio can see. */
|
||||
'GET /devices': async (req, res) => {
|
||||
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
if (missing) {
|
||||
if (state) state.toolMissing = missing;
|
||||
log(`device list unavailable — ${missing}`);
|
||||
json(res, 200, { options: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
@@ -456,10 +438,6 @@ function listDevices() {
|
||||
});
|
||||
}
|
||||
|
||||
function fsExists(p) {
|
||||
try { return require('fs').existsSync(p); } catch { return false; }
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||
* the app, or copied from any Mac of the same architecture. When it is
|
||||
* missing this will build it, but in the background and never on the event
|
||||
* loop, and from module scope so the device picker can trigger it too — see
|
||||
* ensureCaptureBinary for what both of those cost the first time round.
|
||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||
*
|
||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||
@@ -31,74 +30,6 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
||||
* everything that needs it.
|
||||
*
|
||||
* This lives at module scope rather than inside a listener because of a trap
|
||||
* the first version walked into: the admin page's device picker runs the tool
|
||||
* with --list, but the build only ran when a listener started, and a listener
|
||||
* only starts once a device has been picked. On a machine that got the module
|
||||
* from the catalog — source only, the binary being gitignored — the picker
|
||||
* was empty, and nothing would ever fill it.
|
||||
*
|
||||
* It also must never block. It was a spawnSync once, copied from the LTC
|
||||
* listener where the source is a third the size and builds in seconds. Here
|
||||
* it took nearly two minutes on the production Mac and held the Node event
|
||||
* loop for all of it, freezing every other module, then did it again on the
|
||||
* next retry.
|
||||
*
|
||||
* The binary should normally already be there: built beside the app, or
|
||||
* copied from any Mac of the same architecture. This is the fallback.
|
||||
*
|
||||
* @returns {string} '' when the tool is ready, otherwise what to say.
|
||||
*/
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return BUILDING;
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})`
|
||||
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return BUILDING;
|
||||
}
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||
retrying every 5 s would only fill the log with the same sentence. */
|
||||
@@ -122,13 +53,64 @@ function createSplListener(opts) {
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
/** '' when the tool is ready, otherwise what to say about it. */
|
||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||
+ 'and copy it next to spl-capture.swift';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||
*
|
||||
* This used to be a spawnSync, copied from the LTC listener where the
|
||||
* source is a third the size and builds in a few seconds. Here it took
|
||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||
* server for every second of it — one module's optional helper took the
|
||||
* whole dashboard down, then did it again on every retry. Nothing that
|
||||
* runs while a service might be on is allowed to hold the event loop.
|
||||
*
|
||||
* The build is a fallback for a module installed from the catalog on a
|
||||
* machine with a Swift toolchain. The normal path is a binary built
|
||||
* alongside the app, or copied from any same-architecture Mac.
|
||||
*
|
||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||
*/
|
||||
function ensureBinary() {
|
||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
} });
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||
if (buildFailed) return BUILD_HELP;
|
||||
|
||||
building = true;
|
||||
log('spl-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false; buildFailed = true;
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture built');
|
||||
buildFailed = false;
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return 'Building the capture tool — this takes a minute or two';
|
||||
}
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
@@ -153,13 +135,9 @@ function createSplListener(opts) {
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in the Timers
|
||||
// module: "building…" forever, with a working binary on disk.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,4 +236,4 @@ function createSplListener(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener, ensureCaptureBinary };
|
||||
module.exports = { createSplListener };
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* GET /services/v2/service_types every service type, with its
|
||||
* parent folder relationship
|
||||
* GET /services/v2/service_types/{id}
|
||||
* GET /services/v2/service_types/{id}/teams every team, including one
|
||||
* nobody is scheduled on
|
||||
* GET /services/v2/service_types/{id}/item_note_categories
|
||||
* the note categories the
|
||||
* items below use, plus one
|
||||
@@ -25,6 +27,15 @@
|
||||
* "Starts in" state is visible
|
||||
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||
* ItemNote compound documents
|
||||
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
|
||||
* records + Team / Person, with
|
||||
* confirmed / unconfirmed /
|
||||
* declined statuses, a team
|
||||
* nobody is on, and people with
|
||||
* and without a photo
|
||||
* GET /avatars/{personId}.svg those photos (initials on a
|
||||
* disc) — the module proxies
|
||||
* them like the real CDN
|
||||
*
|
||||
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
|
||||
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||
@@ -194,6 +205,107 @@ function itemsDocument(planId, include) {
|
||||
|
||||
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Who is scheduled, as Planning Center returns it from
|
||||
* /plans/{id}/team_members?include=team,person: teams in their own order,
|
||||
* people with a position and a scheduling status ("C" confirmed,
|
||||
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
|
||||
* has no picture for — the tile draws their initials instead.
|
||||
*/
|
||||
const TEAMS = [
|
||||
{
|
||||
id: 't1', name: 'Worship', sequence: 1, members: [
|
||||
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
|
||||
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
|
||||
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
|
||||
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
|
||||
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
|
||||
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
|
||||
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't2', name: 'Production', sequence: 2, members: [
|
||||
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
|
||||
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
|
||||
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
|
||||
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
|
||||
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't3', name: 'Hosting', sequence: 3, members: [
|
||||
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
|
||||
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
|
||||
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
|
||||
],
|
||||
},
|
||||
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
|
||||
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
|
||||
];
|
||||
|
||||
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
|
||||
function avatarSvg(name) {
|
||||
const words = String(name).trim().split(/\s+/);
|
||||
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
|
||||
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
|
||||
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
|
||||
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
|
||||
}
|
||||
|
||||
function teamMembersDocument(planId, include, port) {
|
||||
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
const data = [];
|
||||
const included = [];
|
||||
const seenTeams = new Set();
|
||||
for (const team of TEAMS) {
|
||||
for (const m of team.members) {
|
||||
data.push({
|
||||
type: 'PlanPerson',
|
||||
id: m.id,
|
||||
attributes: {
|
||||
name: m.name,
|
||||
status: m.status,
|
||||
team_position_name: m.position,
|
||||
notes: m.notes || null,
|
||||
decline_reason: m.decline || null,
|
||||
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
||||
status_updated_at: iso(bootedAt - 3600000),
|
||||
},
|
||||
relationships: {
|
||||
plan: { data: { type: 'Plan', id: planId } },
|
||||
team: { data: { type: 'Team', id: team.id } },
|
||||
person: { data: { type: 'Person', id: m.person } },
|
||||
},
|
||||
});
|
||||
if (wants.has('person')) {
|
||||
included.push({
|
||||
type: 'Person',
|
||||
id: m.person,
|
||||
attributes: {
|
||||
name: m.name,
|
||||
first_name: m.name.split(' ')[0],
|
||||
last_name: m.name.split(' ').slice(-1)[0],
|
||||
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (wants.has('team') && !seenTeams.has(team.id)) {
|
||||
seenTeams.add(team.id);
|
||||
included.push({
|
||||
type: 'Team',
|
||||
id: team.id,
|
||||
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, included };
|
||||
}
|
||||
|
||||
function folderRecord(f) {
|
||||
return {
|
||||
type: 'Folder',
|
||||
@@ -242,6 +354,16 @@ const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
|
||||
|
||||
// Photos are plain images on Planning Center's CDN — no auth there, none here.
|
||||
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
|
||||
if (avatar) {
|
||||
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
|
||||
if (!person) return notFound(res);
|
||||
const body = avatarSvg(person.name);
|
||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
|
||||
return void res.end(body);
|
||||
}
|
||||
|
||||
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
|
||||
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
|
||||
}
|
||||
@@ -266,6 +388,13 @@ const server = http.createServer((req, res) => {
|
||||
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||
if (!st) return notFound(res);
|
||||
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||
if (parts[2] === 'teams' && parts.length === 3) {
|
||||
return collection(res, TEAMS.map((t) => ({
|
||||
type: 'Team', id: t.id,
|
||||
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
|
||||
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
|
||||
})), url);
|
||||
}
|
||||
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
||||
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
||||
}
|
||||
@@ -286,6 +415,11 @@ const server = http.createServer((req, res) => {
|
||||
if (!plan) return notFound(res);
|
||||
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
|
||||
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
|
||||
if (parts[4] === 'team_members') {
|
||||
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
|
||||
doc.links = { self: url.pathname + url.search };
|
||||
return send(res, 200, doc);
|
||||
}
|
||||
if (parts[4] === 'items') {
|
||||
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||
doc.links = { self: url.pathname + url.search };
|
||||
|
||||
Reference in New Issue
Block a user