Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f542b34b6c | ||
|
|
a1ea9262c6 | ||
|
|
098562c088 | ||
|
|
408dea7923 | ||
|
|
3ca960baf6 | ||
|
|
882a418a0f | ||
|
|
0e618ea5d3 | ||
|
|
c8b51c6c2f | ||
|
|
c464e5b4ca | ||
|
|
f4d50f5e95 | ||
|
|
c73d0a3aac | ||
|
|
441cfe3882 | ||
|
|
7ca65a845f | ||
|
|
e383070a9e | ||
|
|
2af7ef27ba | ||
|
|
5107b06f8c | ||
|
|
5fb90190ae | ||
|
|
684f06abed | ||
|
|
3435a0625e |
@@ -44,6 +44,7 @@ title-bar controls). The rules that matter most:
|
|||||||
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
|
||||||
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||||
|
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||||
|
|
||||||
## Reference code (read-only forks — don't develop these here)
|
## Reference code (read-only forks — don't develop these here)
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,14 @@ below. Nothing is installed without someone clicking.
|
|||||||
place and shows nothing, then appears when it is; it is never replaced by
|
place and shows nothing, then appears when it is; it is never replaced by
|
||||||
the module's default tile. Saving over an existing name asks first, and
|
the module's default tile. Saving over an existing name asks first, and
|
||||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||||
Saved layouts swaps it back (and swapping again undoes that).
|
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||||
|
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||||
|
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
|
||||||
|
4K wall), coarser suits a small screen read close up; row height follows,
|
||||||
|
so tiles keep their shape and simply change size. Going coarser, a tile
|
||||||
|
that no longer fits where it was moves to the first free spot rather than
|
||||||
|
onto another tile. It saves with the layout, so a named layout comes back
|
||||||
|
at the density it was arranged on.
|
||||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||||
automatically.
|
automatically.
|
||||||
@@ -321,7 +328,14 @@ below. Nothing is installed without someone clicking.
|
|||||||
description and item notes. When the ProPresenter module is running it
|
description and item notes. When the ProPresenter module is running it
|
||||||
follows that module's live item, highlights the matching plan item, dims
|
follows that module's live item, highlights the matching plan item, dims
|
||||||
what's done and shows the elapsed vs planned runtime and how far
|
what's done and shows the elapsed vs planned runtime and how far
|
||||||
ahead/behind the service is. Admin config is the connection: Planning
|
ahead/behind the service is. Items are matched by name — a document named
|
||||||
|
like the Planning Center item or its song, tags and all. The message is
|
||||||
|
the exception: a sermon's document is named after the sermon, so a live
|
||||||
|
document that reads like the plan itself — its date code, series title
|
||||||
|
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
||||||
|
message and lands on the plan's message item (an item titled Message,
|
||||||
|
Sermon or Teaching, else the first item under such a header). Admin
|
||||||
|
config is the connection: Planning
|
||||||
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
||||||
token), the service type (a list grouped by your Planning Center folders)
|
token), the service type (a list grouped by your Planning Center folders)
|
||||||
and an optional pinned plan. Everything about the display is per tile
|
and an optional pinned plan. Everything about the display is per tile
|
||||||
@@ -351,6 +365,41 @@ below. Nothing is installed without someone clicking.
|
|||||||
as `Label | Message` — add `| #channel` to bind one to a channel — and up
|
as `Label | Message` — add `| #channel` to bind one to a channel — and up
|
||||||
to eight quick emojis).
|
to eight quick emojis).
|
||||||
Tile settings: avatars, timestamps, names and channel notices.
|
Tile settings: avatars, timestamps, names and channel notices.
|
||||||
|
- **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
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
@@ -362,8 +411,11 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
|||||||
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
|
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
|
||||||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
|
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
|
||||||
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
|
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
|
||||||
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
|
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
||||||
testing section of the [module guide](docs/MODULE-GUIDE.md).
|
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
||||||
|
board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||||
|
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
||||||
|
the [module guide](docs/MODULE-GUIDE.md).
|
||||||
|
|
||||||
## Install as a desktop web app
|
## Install as a desktop web app
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,37 @@
|
|||||||
<option value="">Choose service type</option>
|
<option value="">Choose service type</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
|
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btn-pco-test-birthdays">Test Birthdays</button>
|
||||||
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
|
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
|
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
|
||||||
|
<div class="pco-status pco-birthday-result" id="pco-birthday-result"></div>
|
||||||
|
|
||||||
|
<div class="bday-block">
|
||||||
|
<div class="bday-head">
|
||||||
|
<span class="bday-title">Birthday Mode 🎂</span>
|
||||||
|
<label class="bday-switch">
|
||||||
|
<input type="checkbox" id="bday-enabled" />
|
||||||
|
<span class="bday-switch-label">On</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="bday-controls" id="bday-controls">
|
||||||
|
<div class="bday-line">
|
||||||
|
<span class="bday-line-label">Intensity</span>
|
||||||
|
<div class="seg-inline" id="bday-intensity" role="group" aria-label="Birthday intensity">
|
||||||
|
<button type="button" data-int="subtle">Subtle</button>
|
||||||
|
<button type="button" data-int="full">Full</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bday-line">
|
||||||
|
<span class="bday-line-label">Force on <em>(test)</em></span>
|
||||||
|
<select id="bday-test" class="pco-select bday-test-select" aria-label="Force birthday on a person for testing">
|
||||||
|
<option value="">Off</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="bday-hint">Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="pco-position-builder">
|
<div class="pco-position-builder">
|
||||||
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
|
||||||
<option value="">Choose position</option>
|
<option value="">Choose position</option>
|
||||||
|
|||||||
+167
-1
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
|
|||||||
return payload || { data: [] };
|
return payload || { data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell
|
||||||
|
* "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People
|
||||||
|
* birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do.
|
||||||
|
*/
|
||||||
|
async function pcoFetchStatus(target, credentials, searchParams) {
|
||||||
|
const url = buildPcoUrl(target, searchParams);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: pcoAuthHeader(credentials),
|
||||||
|
'User-Agent': pcoUserAgent(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
return { status: response.status, ok: response.ok, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People
|
||||||
|
* share the same person id, so the Services person id we already store doubles as the People id.
|
||||||
|
* Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent.
|
||||||
|
*/
|
||||||
|
async function pcoFetchBirthday(personId, credentials) {
|
||||||
|
if (!personId) return { status: 0, birthday: '', fieldPresent: false };
|
||||||
|
const { status, ok, payload } = await pcoFetchStatus(
|
||||||
|
`/people/v2/people/${encodeURIComponent(personId)}`,
|
||||||
|
credentials,
|
||||||
|
{ 'fields[Person]': 'birthdate' },
|
||||||
|
);
|
||||||
|
if (!ok) return { status, birthday: '', fieldPresent: false };
|
||||||
|
const attrs = payload?.data?.attributes || {};
|
||||||
|
return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs };
|
||||||
|
}
|
||||||
|
|
||||||
async function pcoFetchAll(target, credentials, searchParams) {
|
async function pcoFetchAll(target, credentials, searchParams) {
|
||||||
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -275,6 +316,15 @@ function emptyState() {
|
|||||||
pcoDisplayPositions: [],
|
pcoDisplayPositions: [],
|
||||||
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
||||||
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
||||||
|
birthdayMode: { enabled: true, intensity: 'subtle' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
|
||||||
|
function normalizeBirthdayMode(entry) {
|
||||||
|
return {
|
||||||
|
enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
|
||||||
|
intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
|
|||||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
||||||
|
// "MM-DD" only — we deliberately never persist the birth year (month + day is all
|
||||||
|
// Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file.
|
||||||
|
birthday: normalizeBirthdayMmdd(person?.birthday),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */
|
||||||
|
function normalizeBirthdayMmdd(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape
|
||||||
|
if (full) return `${full[1]}-${full[2]}`;
|
||||||
|
const md = s.match(/^(\d{2})-(\d{2})$/);
|
||||||
|
if (md) {
|
||||||
|
const mm = Number(md[1]);
|
||||||
|
const dd = Number(md[2]);
|
||||||
|
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTeamName(value) {
|
function normalizeTeamName(value) {
|
||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
|
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a
|
||||||
|
// person with no date on file just yields '' and never fails the sync). Fetched once per unique
|
||||||
|
// person so a member in two positions isn't queried twice.
|
||||||
|
const birthdayByPerson = new Map();
|
||||||
|
for (const member of scheduledMembers) {
|
||||||
|
if (!member.personId || birthdayByPerson.has(member.personId)) continue;
|
||||||
|
try {
|
||||||
|
const { birthday } = await pcoFetchBirthday(member.personId, credentials);
|
||||||
|
birthdayByPerson.set(member.personId, birthday || '');
|
||||||
|
} catch {
|
||||||
|
birthdayByPerson.set(member.personId, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const oldPcoFiles = new Set(
|
const oldPcoFiles = new Set(
|
||||||
(Array.isArray(baseState.library) ? baseState.library : [])
|
(Array.isArray(baseState.library) ? baseState.library : [])
|
||||||
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
||||||
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
source: 'pco',
|
source: 'pco',
|
||||||
pcoPersonId: member.personId,
|
pcoPersonId: member.personId,
|
||||||
pcoPlanPersonId: member.planPersonId,
|
pcoPlanPersonId: member.planPersonId,
|
||||||
|
birthday: birthdayByPerson.get(member.personId) || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
const atWriteTime = await loadStateRaw();
|
const atWriteTime = await loadStateRaw();
|
||||||
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
||||||
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
||||||
|
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
|
||||||
await saveStateRaw(nextState);
|
await saveStateRaw(nextState);
|
||||||
|
|
||||||
for (const fileName of oldPcoFiles) {
|
for (const fileName of oldPcoFiles) {
|
||||||
@@ -1222,6 +1305,7 @@ function migrateState(data) {
|
|||||||
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
||||||
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
||||||
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
||||||
|
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
|
||||||
lineup: data.lineup.map((e) => {
|
lineup: data.lineup.map((e) => {
|
||||||
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
||||||
return {
|
return {
|
||||||
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
|
|||||||
// The renderer never edits this (it is changed through the display-* IPC handlers), so
|
// The renderer never edits this (it is changed through the display-* IPC handlers), so
|
||||||
// always carry the stored value forward rather than letting a save blank it out.
|
// always carry the stored value forward rather than letting a save blank it out.
|
||||||
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
||||||
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
|
const birthdayMode = payload?.birthdayMode
|
||||||
|
? normalizeBirthdayMode(payload.birthdayMode)
|
||||||
|
: normalizeBirthdayMode(existing.birthdayMode);
|
||||||
|
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
|
|||||||
return syncUpcomingPcoPlan(payload);
|
return syncUpcomingPcoPlan(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
|
||||||
|
* People API and read birthdates, and how many synced band members have one on file. Read-only.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('pco-test-birthdays', async () => {
|
||||||
|
const credentials = await loadPcoCredentials();
|
||||||
|
if (!credentials) return { connected: false };
|
||||||
|
|
||||||
|
// 1. Can the token read the People API at all?
|
||||||
|
let probe;
|
||||||
|
try {
|
||||||
|
probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
|
||||||
|
} catch (err) {
|
||||||
|
return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
if (!probe.ok) {
|
||||||
|
const detail =
|
||||||
|
probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
|
||||||
|
`${probe.status}`;
|
||||||
|
return { connected: true, canRead: false, status: probe.status, detail };
|
||||||
|
}
|
||||||
|
const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
|
||||||
|
const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
|
||||||
|
|
||||||
|
// 2. Coverage across the people we've actually synced (unique by person id).
|
||||||
|
const state = await loadStateRaw();
|
||||||
|
const seen = new Set();
|
||||||
|
const targets = [];
|
||||||
|
for (const p of Array.isArray(state.library) ? state.library : []) {
|
||||||
|
if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
|
||||||
|
seen.add(p.pcoPersonId);
|
||||||
|
targets.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
let withBirthday = 0;
|
||||||
|
let notFound = 0;
|
||||||
|
let errors = 0;
|
||||||
|
const found = [];
|
||||||
|
for (const p of targets) {
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await pcoFetchBirthday(p.pcoPersonId, credentials);
|
||||||
|
} catch {
|
||||||
|
errors += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.status === 404) { notFound += 1; continue; }
|
||||||
|
if (r.status && r.status >= 400) { errors += 1; continue; }
|
||||||
|
if (r.birthday) {
|
||||||
|
withBirthday += 1;
|
||||||
|
found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soonest upcoming birthday, for a concrete "it works" line.
|
||||||
|
const today = new Date();
|
||||||
|
const daysUntil = (mmdd) => {
|
||||||
|
const [mm, dd] = mmdd.split('-').map(Number);
|
||||||
|
let next = new Date(today.getFullYear(), mm - 1, dd);
|
||||||
|
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||||
|
if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
|
||||||
|
return Math.round((next - midnight) / 86400000);
|
||||||
|
};
|
||||||
|
const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
|
||||||
|
const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
canRead: true,
|
||||||
|
fieldExposed,
|
||||||
|
total: targets.length,
|
||||||
|
withBirthday,
|
||||||
|
notFound,
|
||||||
|
errors,
|
||||||
|
found: sortedFound.slice(0, 8),
|
||||||
|
nextUp,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('pp-status', async () => {
|
ipcMain.handle('pp-status', async () => {
|
||||||
return {
|
return {
|
||||||
enabled: ppConfig.enabled,
|
enabled: ppConfig.enabled,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "band-lineup-display",
|
"name": "band-lineup-display",
|
||||||
"productName": "Band Lineup",
|
"productName": "Band Lineup",
|
||||||
"version": "1.3.6",
|
"version": "1.3.10",
|
||||||
"buildNumber": "92",
|
"buildNumber": "96",
|
||||||
"description": "Stage lineup photos with mic/pack labels",
|
"description": "Stage lineup photos with mic/pack labels",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
|
|||||||
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
|
||||||
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
|
||||||
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
|
||||||
|
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
|
||||||
onMenuAddPhotos: (cb) => {
|
onMenuAddPhotos: (cb) => {
|
||||||
ipcRenderer.on('menu-add-photos', () => cb());
|
ipcRenderer.on('menu-add-photos', () => cb());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
|
|||||||
} else {
|
} else {
|
||||||
window.__bandLineupRendererLoaded = true;
|
window.__bandLineupRendererLoaded = true;
|
||||||
/**
|
/**
|
||||||
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
|
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
|
||||||
* @typedef {{ personId: string, label: string }} StageSlot
|
* @typedef {{ personId: string, label: string }} StageSlot
|
||||||
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
|
|||||||
let pcoPositionOptions = [];
|
let pcoPositionOptions = [];
|
||||||
let slotLabelMemory = [];
|
let slotLabelMemory = [];
|
||||||
let slotNameMemory = [];
|
let slotNameMemory = [];
|
||||||
|
/** Birthday Mode display settings (persisted). */
|
||||||
|
let birthdayMode = { enabled: true, intensity: 'subtle' };
|
||||||
|
/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */
|
||||||
|
let birthdayTestPersonId = '';
|
||||||
/** @type {StageSlot[]} */
|
/** @type {StageSlot[]} */
|
||||||
let stageSlots = createEmptySlots();
|
let stageSlots = createEmptySlots();
|
||||||
const imageUrlCache = new Map();
|
const imageUrlCache = new Map();
|
||||||
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
|
|||||||
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
|
||||||
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
const btnPcoConnect = document.getElementById('btn-pco-connect');
|
||||||
const btnPcoSync = document.getElementById('btn-pco-sync');
|
const btnPcoSync = document.getElementById('btn-pco-sync');
|
||||||
|
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
|
||||||
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
|
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
|
||||||
|
const pcoBirthdayResult = document.getElementById('pco-birthday-result');
|
||||||
|
const bdayEnabled = document.getElementById('bday-enabled');
|
||||||
|
const bdayIntensity = document.getElementById('bday-intensity');
|
||||||
|
const bdayControls = document.getElementById('bday-controls');
|
||||||
|
const bdayTestSelect = document.getElementById('bday-test');
|
||||||
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
|
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
|
||||||
const inputLayoutCols = document.getElementById('layout-cols');
|
const inputLayoutCols = document.getElementById('layout-cols');
|
||||||
const inputLayoutRows = document.getElementById('layout-rows');
|
const inputLayoutRows = document.getElementById('layout-rows');
|
||||||
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
|
|||||||
function setPcoControlsEnabled(enabled) {
|
function setPcoControlsEnabled(enabled) {
|
||||||
btnPcoConnect.disabled = !enabled;
|
btnPcoConnect.disabled = !enabled;
|
||||||
btnPcoSync.disabled = !enabled;
|
btnPcoSync.disabled = !enabled;
|
||||||
|
btnPcoTestBirthdays.disabled = !enabled;
|
||||||
btnPcoDisconnect.disabled = !enabled;
|
btnPcoDisconnect.disabled = !enabled;
|
||||||
btnPcoAddPosition.disabled = !enabled;
|
btnPcoAddPosition.disabled = !enabled;
|
||||||
pcoServiceTypeSelect.disabled = !enabled;
|
pcoServiceTypeSelect.disabled = !enabled;
|
||||||
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
|
|||||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
||||||
|
// Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js
|
||||||
|
// re-validates the "MM-DD" shape on write); Birthday Mode reads this.
|
||||||
|
birthday: typeof person?.birthday === 'string' ? person.birthday : '',
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
|
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
|
||||||
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
|
|||||||
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
pcoServiceTypeSelect.value = pcoServiceTypeId;
|
||||||
renderPcoDisplayPositions();
|
renderPcoDisplayPositions();
|
||||||
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
|
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
|
||||||
|
birthdayMode = {
|
||||||
|
enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled),
|
||||||
|
intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle',
|
||||||
|
};
|
||||||
|
syncBirthdayControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSlotSizeReadout() {
|
function updateSlotSizeReadout() {
|
||||||
@@ -528,6 +547,7 @@ function scheduleSave() {
|
|||||||
slotLabels: slotLabelMemory,
|
slotLabels: slotLabelMemory,
|
||||||
slotNames: slotNameMemory,
|
slotNames: slotNameMemory,
|
||||||
lineup: stageSlots,
|
lineup: stageSlots,
|
||||||
|
birthdayMode,
|
||||||
});
|
});
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -950,6 +970,132 @@ function parallaxMotionVars(seedText) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Birthday Mode ---------------- */
|
||||||
|
|
||||||
|
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
|
||||||
|
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
|
||||||
|
const birthdayCanvases = new Set();
|
||||||
|
let birthdayRaf = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
|
||||||
|
* anywhere in this window celebrates all week (so it's already up for the weekend services).
|
||||||
|
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
|
||||||
|
*/
|
||||||
|
let birthdayWeekCache = { key: '', set: null };
|
||||||
|
function birthdayWeekSet() {
|
||||||
|
const now = new Date();
|
||||||
|
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||||||
|
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
|
||||||
|
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
|
||||||
|
const set = new Set();
|
||||||
|
for (let i = 0; i < 7; i += 1) {
|
||||||
|
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||||
|
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
birthdayWeekCache = { key, set };
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
|
||||||
|
function isBirthdayActive(person) {
|
||||||
|
if (!birthdayMode.enabled || !person) return false;
|
||||||
|
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
|
||||||
|
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
|
||||||
|
function birthdayTick() {
|
||||||
|
const before = birthdayWeekCache.key;
|
||||||
|
birthdayWeekSet();
|
||||||
|
if (before && birthdayWeekCache.key !== before) void renderStage();
|
||||||
|
}
|
||||||
|
setInterval(birthdayTick, 15 * 60 * 1000);
|
||||||
|
|
||||||
|
function birthdayMakeParticle(w, h, intensity, seedY) {
|
||||||
|
const ribbon = Math.random() < 0.55;
|
||||||
|
return {
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: seedY != null ? seedY : Math.random() * h,
|
||||||
|
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
|
||||||
|
h: ribbon ? 9 + Math.random() * 8 : 0,
|
||||||
|
ribbon,
|
||||||
|
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
|
||||||
|
rot: Math.random() * Math.PI * 2,
|
||||||
|
vr: -0.14 + Math.random() * 0.28,
|
||||||
|
sway: 0.6 + Math.random() * 1.1,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdaySeedCanvas(c, w, h) {
|
||||||
|
const base = c._intensity === 'subtle' ? 26 : 64;
|
||||||
|
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
|
||||||
|
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
|
||||||
|
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
|
||||||
|
function birthdayStepCanvas(c) {
|
||||||
|
const w = c.clientWidth;
|
||||||
|
const h = c.clientHeight;
|
||||||
|
if (!w || !h) return; // not laid out yet — try again next frame
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
if (c._w !== w || c._h !== h) {
|
||||||
|
c.width = w * dpr;
|
||||||
|
c.height = h * dpr;
|
||||||
|
c._ctx = c.getContext('2d');
|
||||||
|
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
c._w = w;
|
||||||
|
c._h = h;
|
||||||
|
birthdaySeedCanvas(c, w, h);
|
||||||
|
}
|
||||||
|
const ctx = c._ctx;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
for (const p of c._parts) {
|
||||||
|
p.phase += 0.03;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.x += Math.sin(p.phase) * p.sway;
|
||||||
|
p.rot += p.vr;
|
||||||
|
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate(p.rot);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||||
|
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdayLoop() {
|
||||||
|
for (const c of [...birthdayCanvases]) {
|
||||||
|
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
|
||||||
|
birthdayStepCanvas(c);
|
||||||
|
}
|
||||||
|
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdayRegisterCanvas(canvas, intensity) {
|
||||||
|
canvas._intensity = intensity;
|
||||||
|
birthdayCanvases.add(canvas);
|
||||||
|
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
|
||||||
|
function applyBirthdayEffects(tile, photoArea) {
|
||||||
|
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
|
||||||
|
tile.classList.add('birthday', `birthday-${intensity}`);
|
||||||
|
|
||||||
|
const confetti = document.createElement('canvas');
|
||||||
|
confetti.className = 'birthday-confetti';
|
||||||
|
confetti.setAttribute('aria-hidden', 'true');
|
||||||
|
photoArea.appendChild(confetti);
|
||||||
|
birthdayRegisterCanvas(confetti, intensity);
|
||||||
|
}
|
||||||
|
|
||||||
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
||||||
void parallaxAssets;
|
void parallaxAssets;
|
||||||
const tile = document.createElement('article');
|
const tile = document.createElement('article');
|
||||||
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
|
|||||||
|
|
||||||
photoArea.appendChild(nameBadge);
|
photoArea.appendChild(nameBadge);
|
||||||
photoArea.appendChild(removeBtn);
|
photoArea.appendChild(removeBtn);
|
||||||
|
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
|
||||||
tile.appendChild(photoArea);
|
tile.appendChild(photoArea);
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
|
|||||||
async function renderAll() {
|
async function renderAll() {
|
||||||
await renderStage();
|
await renderStage();
|
||||||
await renderLibrary();
|
await renderLibrary();
|
||||||
|
renderBirthdayTestOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importNewPeople(added) {
|
async function importNewPeople(added) {
|
||||||
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
|
|||||||
source: 'local',
|
source: 'local',
|
||||||
pcoPersonId: '',
|
pcoPersonId: '',
|
||||||
pcoPlanPersonId: '',
|
pcoPlanPersonId: '',
|
||||||
|
birthday: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await renderLibrary();
|
await renderLibrary();
|
||||||
@@ -1232,6 +1381,97 @@ async function syncUpcomingPlanFromPco() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
function formatBirthdayMmdd(mmdd) {
|
||||||
|
const parts = String(mmdd || '').split('-');
|
||||||
|
const mm = Number(parts[0]);
|
||||||
|
const dd = Number(parts[1]);
|
||||||
|
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
|
||||||
|
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPcoBirthdayResult(message, tone) {
|
||||||
|
if (!pcoBirthdayResult) return;
|
||||||
|
pcoBirthdayResult.textContent = message || '';
|
||||||
|
pcoBirthdayResult.style.display = message ? '' : 'none';
|
||||||
|
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPcoBirthdays() {
|
||||||
|
setPcoControlsEnabled(false);
|
||||||
|
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
|
||||||
|
try {
|
||||||
|
const r = await window.lineup.pcoTestBirthdays();
|
||||||
|
if (!r || r.connected === false) {
|
||||||
|
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.canRead) {
|
||||||
|
const why = r.reason === 'network'
|
||||||
|
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
|
||||||
|
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
|
||||||
|
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Access works. Report coverage across synced band members.
|
||||||
|
if (r.total === 0) {
|
||||||
|
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
|
||||||
|
if (r.nextUp) {
|
||||||
|
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
|
||||||
|
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
|
||||||
|
}
|
||||||
|
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
|
||||||
|
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
|
||||||
|
} catch (error) {
|
||||||
|
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
setPcoControlsEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Birthday Mode controls ---------------- */
|
||||||
|
|
||||||
|
/** Reflect current birthdayMode settings onto the controls (called after load). */
|
||||||
|
function syncBirthdayControls() {
|
||||||
|
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
|
||||||
|
if (bdayIntensity) {
|
||||||
|
for (const b of bdayIntensity.querySelectorAll('button')) {
|
||||||
|
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
|
||||||
|
function renderBirthdayTestOptions() {
|
||||||
|
if (!bdayTestSelect) return;
|
||||||
|
const previous = birthdayTestPersonId;
|
||||||
|
bdayTestSelect.innerHTML = '<option value="">Off</option>';
|
||||||
|
const seen = new Set();
|
||||||
|
stageSlots.forEach((slot, index) => {
|
||||||
|
if (!slot.personId || seen.has(slot.personId)) return;
|
||||||
|
const person = getPerson(slot.personId);
|
||||||
|
if (!person) return;
|
||||||
|
seen.add(slot.personId);
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = person.id;
|
||||||
|
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
|
||||||
|
const label = String(slotLabelMemory[index] || '').trim();
|
||||||
|
option.textContent = label ? `${name} — ${label}` : name;
|
||||||
|
bdayTestSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
// Keep the current override selected if that person is still on stage; otherwise drop it.
|
||||||
|
if (previous && seen.has(previous)) {
|
||||||
|
bdayTestSelect.value = previous;
|
||||||
|
} else if (previous) {
|
||||||
|
birthdayTestPersonId = '';
|
||||||
|
bdayTestSelect.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Setup panel tabs ---------------- */
|
/* ---------------- Setup panel tabs ---------------- */
|
||||||
|
|
||||||
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
|
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
|
||||||
@@ -1257,6 +1497,7 @@ function activatePanelTab(name) {
|
|||||||
}
|
}
|
||||||
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
|
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
|
||||||
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
|
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
|
||||||
|
if (name === 'pco') renderBirthdayTestOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
|
||||||
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
|
|||||||
void syncUpcomingPlanFromPco();
|
void syncUpcomingPlanFromPco();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
btnPcoTestBirthdays.addEventListener('click', () => {
|
||||||
|
revealChrome();
|
||||||
|
void testPcoBirthdays();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bdayEnabled) {
|
||||||
|
bdayEnabled.addEventListener('change', () => {
|
||||||
|
birthdayMode.enabled = bdayEnabled.checked;
|
||||||
|
syncBirthdayControls();
|
||||||
|
scheduleSave();
|
||||||
|
void renderStage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bdayIntensity) {
|
||||||
|
bdayIntensity.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-int]');
|
||||||
|
if (!btn) return;
|
||||||
|
birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle';
|
||||||
|
syncBirthdayControls();
|
||||||
|
scheduleSave();
|
||||||
|
void renderStage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bdayTestSelect) {
|
||||||
|
bdayTestSelect.addEventListener('change', () => {
|
||||||
|
birthdayTestPersonId = bdayTestSelect.value || '';
|
||||||
|
void renderStage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
btnPcoDisconnect.addEventListener('click', async () => {
|
btnPcoDisconnect.addEventListener('click', async () => {
|
||||||
revealChrome();
|
revealChrome();
|
||||||
setPcoControlsEnabled(false);
|
setPcoControlsEnabled(false);
|
||||||
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
|
|||||||
renderPcoPositionOptions();
|
renderPcoPositionOptions();
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
setPcoStatus('Planning Center disconnected.');
|
setPcoStatus('Planning Center disconnected.');
|
||||||
|
setPcoBirthdayResult('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -331,6 +331,105 @@ body {
|
|||||||
color: #9ea7bc;
|
color: #9ea7bc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pco-birthday-result {
|
||||||
|
min-height: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pco-birthday-result:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Birthday Mode settings ---- */
|
||||||
|
.bday-block {
|
||||||
|
margin-top: 14px;
|
||||||
|
border-top: 1px solid #23232c;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.bday-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.bday-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e7e7ef;
|
||||||
|
}
|
||||||
|
.bday-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ea7bc;
|
||||||
|
}
|
||||||
|
.bday-controls {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.bday-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bday-line-label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8a8a99;
|
||||||
|
min-width: 74px;
|
||||||
|
}
|
||||||
|
.bday-line-label em {
|
||||||
|
font-style: normal;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: #6d6d7a;
|
||||||
|
}
|
||||||
|
.seg-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: #0e0e14;
|
||||||
|
border: 1px solid #23232c;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.seg-inline button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ea7bc;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg-inline button[aria-pressed="true"] {
|
||||||
|
background: #f5c451;
|
||||||
|
color: #241a02;
|
||||||
|
}
|
||||||
|
.bday-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c6cada;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bday-test-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
.bday-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6d6d7a;
|
||||||
|
}
|
||||||
|
|
||||||
.pco-position-builder {
|
.pco-position-builder {
|
||||||
display: none;
|
display: none;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -624,6 +723,33 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Birthday Mode tile effects ---- */
|
||||||
|
.tile.birthday-subtle {
|
||||||
|
animation: bdayGlowSubtle 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.tile.birthday-full {
|
||||||
|
animation: bdayGlowFull 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes bdayGlowSubtle {
|
||||||
|
0%, 100% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.55), 0 0 14px rgba(245, 196, 81, 0.18); }
|
||||||
|
50% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.85), 0 0 20px rgba(245, 196, 81, 0.32); }
|
||||||
|
}
|
||||||
|
@keyframes bdayGlowFull {
|
||||||
|
0%, 100% { box-shadow: inset 0 0 0 3px rgba(245, 196, 81, 0.8), 0 0 20px rgba(245, 196, 81, 0.42); }
|
||||||
|
50% { box-shadow: inset 0 0 0 3px #ffd873, 0 0 34px rgba(245, 196, 81, 0.66); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Birthday Mode intentionally keeps animating even under OS "reduced motion" — it's a celebration
|
||||||
|
the operator opts into, and freezing it leaves confetti stuck mid-fall. */
|
||||||
|
|
||||||
|
.birthday-confetti {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.tile .remove {
|
.tile .remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
| `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||||
|
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||||
|
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||||
|
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||||
|
// (Wed→Tue) rains confetti.
|
||||||
|
|
||||||
|
const CONFETTI_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let stream = null;
|
||||||
|
let state = null;
|
||||||
|
let editing = false;
|
||||||
|
let choirBtn = null;
|
||||||
|
let selectedLibId = ''; // click-to-place fallback for touch
|
||||||
|
const canvases = new Set();
|
||||||
|
let raf = null;
|
||||||
|
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||||
|
let dragScrolling = false;
|
||||||
|
let dragY = 0;
|
||||||
|
let dragBody = null;
|
||||||
|
let dragRaf = null;
|
||||||
|
function startDragAutoScroll() {
|
||||||
|
if (dragRaf) return;
|
||||||
|
const tick = () => {
|
||||||
|
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||||
|
const r = dragBody.getBoundingClientRect();
|
||||||
|
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||||
|
let dy = 0;
|
||||||
|
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||||
|
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||||
|
if (dy) dragBody.scrollTop += dy;
|
||||||
|
dragRaf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
dragRaf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||||
|
|
||||||
|
/* ---------- confetti (always animates) ---------- */
|
||||||
|
function makeParticle(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: CONFETTI_COLORS[(Math.random() * CONFETTI_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 seed(c, w, h) { const base = c._intensity === 'subtle' ? 26 : 64; const n = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); c._parts = Array.from({ length: n }, () => makeParticle(w, h, c._intensity)); }
|
||||||
|
function step(c) {
|
||||||
|
const w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
|
||||||
|
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; seed(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, makeParticle(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 loop() { for (const c of [...canvases]) { if (!c.isConnected) { canvases.delete(c); continue; } step(c); } raf = canvases.size ? requestAnimationFrame(loop) : null; }
|
||||||
|
function addConfetti(host, intensity) { const c = document.createElement('canvas'); c.className = 'bl-confetti'; c._intensity = intensity; host.appendChild(c); canvases.add(c); if (!raf) raf = requestAnimationFrame(loop); }
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
function initials(name) { const p = String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); return p.length ? p.map((x) => x[0]).join('').toUpperCase() : ''; }
|
||||||
|
// Per-tile Columns wins; 0 falls back to the admin default.
|
||||||
|
function resolveCols() { const per = Number(moduleApi.instanceSettings.columns) || 0; return Math.max(1, per > 0 ? per : (Number(state && state.columns) || 7)); }
|
||||||
|
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||||
|
function photoNode(person, cls) {
|
||||||
|
if (person && person.photo) { const img = el('img', cls || 'bl-img'); img.alt = ''; img.src = person.photo; img.loading = 'lazy'; return img; }
|
||||||
|
return el('div', 'bl-initials', initials(person && person.name));
|
||||||
|
}
|
||||||
|
async function api(pathStr, opts) { try { return await moduleApi.fetch(pathStr, opts); } catch { return null; } }
|
||||||
|
|
||||||
|
/* ---------- display board ---------- */
|
||||||
|
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
stopConfetti();
|
||||||
|
stopParallax();
|
||||||
|
root.innerHTML = '';
|
||||||
|
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||||
|
if (editing) { renderEditor(); return; }
|
||||||
|
|
||||||
|
const board = el('div', 'bl-board');
|
||||||
|
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||||
|
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
const birthdays = s.birthdays !== false;
|
||||||
|
const intensity = s.birthdayIntensity === 'full' ? 'full' : 'subtle';
|
||||||
|
const testIndex = birthdays ? String(s.birthdayTest || '') : '';
|
||||||
|
const cols = resolveCols();
|
||||||
|
const rows = Math.max(1, Math.ceil((state.slots ? state.slots.length : 1) / cols));
|
||||||
|
board.style.setProperty('--bl-cols', cols);
|
||||||
|
board.style.setProperty('--bl-rows', rows);
|
||||||
|
|
||||||
|
for (const slot of state.slots || []) {
|
||||||
|
const cell = el('div', 'bl-slot');
|
||||||
|
const person = slot.person;
|
||||||
|
if (person) {
|
||||||
|
const tile = el('div', 'bl-tile');
|
||||||
|
const photo = el('div', 'bl-photo');
|
||||||
|
photo.appendChild(photoNode(person));
|
||||||
|
const celebrating = birthdays && (person.celebrating || (testIndex !== '' && testIndex === String(slot.index)));
|
||||||
|
if (celebrating) { tile.classList.add('bl-birthday', `bl-birthday-${intensity}`); addConfetti(photo, intensity); }
|
||||||
|
if (s.showNames !== false && person.name) photo.appendChild(el('div', 'bl-name', person.name));
|
||||||
|
tile.appendChild(photo);
|
||||||
|
if (s.showLabels !== false && slot.label) tile.appendChild(el('div', 'bl-label', slot.label));
|
||||||
|
cell.appendChild(tile);
|
||||||
|
} else {
|
||||||
|
const empty = el('div', 'bl-tile bl-empty');
|
||||||
|
empty.appendChild(el('div', 'bl-label bl-label-empty', slot.label || `Slot ${slot.index + 1}`));
|
||||||
|
cell.appendChild(empty);
|
||||||
|
}
|
||||||
|
board.appendChild(cell);
|
||||||
|
}
|
||||||
|
root.appendChild(board);
|
||||||
|
applyParallax(board, s.parallax !== false);
|
||||||
|
reportStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- parallax: each photo drifts on its own, automatically ---------- */
|
||||||
|
let parallaxRaf = null;
|
||||||
|
let parallaxImgs = [];
|
||||||
|
function stopParallax() { if (parallaxRaf) { cancelAnimationFrame(parallaxRaf); parallaxRaf = null; } parallaxImgs = []; }
|
||||||
|
function applyParallax(board, on) {
|
||||||
|
stopParallax();
|
||||||
|
const imgs = [...board.querySelectorAll('.bl-img')];
|
||||||
|
if (!on) { imgs.forEach((i) => { i.style.transform = ''; }); return; }
|
||||||
|
// Give each image its own gentle, endless drift — different amplitude, speed and phase,
|
||||||
|
// so the wall never looks frozen and no two photos move together. No mouse needed.
|
||||||
|
parallaxImgs = imgs.map((img) => ({
|
||||||
|
img,
|
||||||
|
ax: 4 + Math.random() * 5, ay: 4 + Math.random() * 5,
|
||||||
|
// Very slow: a full drift cycle takes roughly a minute, so the motion only
|
||||||
|
// registers if you actually watch for it.
|
||||||
|
sx: 0.03 + Math.random() * 0.05, sy: 0.03 + Math.random() * 0.05,
|
||||||
|
px: Math.random() * Math.PI * 2, py: Math.random() * Math.PI * 2,
|
||||||
|
}));
|
||||||
|
const tick = () => {
|
||||||
|
const t = performance.now() / 1000;
|
||||||
|
for (const p of parallaxImgs) {
|
||||||
|
if (!p.img.isConnected) continue;
|
||||||
|
p.img.style.transform = `scale(1.08) translate(${(Math.sin(t * p.sx + p.px) * p.ax).toFixed(2)}px, ${(Math.cos(t * p.sy + p.py) * p.ay).toFixed(2)}px)`;
|
||||||
|
}
|
||||||
|
parallaxRaf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
parallaxRaf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportStatus() {
|
||||||
|
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
if (state.configured && !state.reachable) return moduleApi.setStatus('error', state.lastError || 'Planning Center unreachable');
|
||||||
|
return moduleApi.setStatus('ok', state.plan?.dates ? `Plan: ${state.plan.dates}` : 'Ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- editor ---------- */
|
||||||
|
function renderEditor() {
|
||||||
|
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||||
|
// board on top and the picture picker (library) below it.
|
||||||
|
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||||
|
const body = el('div', 'bl-editor-body');
|
||||||
|
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||||
|
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||||
|
|
||||||
|
// Slots (drop targets)
|
||||||
|
const boardEl = el('div', 'bl-edit-board');
|
||||||
|
boardEl.style.setProperty('--bl-cols', resolveCols());
|
||||||
|
for (const slot of state?.slots || []) {
|
||||||
|
const cell = el('div', 'bl-edit-slot');
|
||||||
|
cell.dataset.index = String(slot.index);
|
||||||
|
const photo = el('div', 'bl-edit-photo');
|
||||||
|
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||||
|
cell.appendChild(photo);
|
||||||
|
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||||
|
if (slot.person) {
|
||||||
|
cell.draggable = true;
|
||||||
|
cell.addEventListener('dragstart', (e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||||
|
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||||
|
});
|
||||||
|
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||||
|
}
|
||||||
|
const nameRow = el('div', 'bl-edit-namerow');
|
||||||
|
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||||
|
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||||
|
nameRow.appendChild(nameBtn);
|
||||||
|
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||||
|
cell.appendChild(nameRow);
|
||||||
|
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||||
|
// drop
|
||||||
|
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||||
|
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||||
|
cell.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||||
|
const data = e.dataTransfer.getData('text/plain') || '';
|
||||||
|
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||||
|
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||||
|
});
|
||||||
|
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||||
|
boardEl.appendChild(cell);
|
||||||
|
}
|
||||||
|
body.appendChild(boardEl);
|
||||||
|
|
||||||
|
// Library
|
||||||
|
const lib = el('div', 'bl-library');
|
||||||
|
const list = state?.library || [];
|
||||||
|
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||||
|
for (const person of list) {
|
||||||
|
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||||
|
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||||
|
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||||
|
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||||
|
nm.addEventListener('click', (e) => { e.stopPropagation(); renameLibrary(person.id, person.name); });
|
||||||
|
item.appendChild(nm);
|
||||||
|
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||||
|
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||||
|
item.appendChild(del);
|
||||||
|
item.addEventListener('dragstart', (e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||||
|
});
|
||||||
|
item.addEventListener('dragend', stopDragAutoScroll);
|
||||||
|
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||||
|
lib.appendChild(item);
|
||||||
|
}
|
||||||
|
body.appendChild(lib);
|
||||||
|
wrap.appendChild(body);
|
||||||
|
|
||||||
|
root.innerHTML = '';
|
||||||
|
root.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assign(slotIndex, personId) {
|
||||||
|
selectedLibId = '';
|
||||||
|
api(`/assign?slot=${slotIndex}&personId=${encodeURIComponent(personId)}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
function renameSlot(slotIndex, currentName) {
|
||||||
|
const name = prompt('Name for this spot (leave blank to remove a name-only spot):', currentName || '');
|
||||||
|
if (name === null) return;
|
||||||
|
api(`/slot/${slotIndex}/name?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
function renameLibrary(id, currentName) {
|
||||||
|
const name = prompt('Rename this photo:', currentName || '');
|
||||||
|
if (name === null) return;
|
||||||
|
api(`/library/${id}?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- lifecycle ---------- */
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
|
const SYNC_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>';
|
||||||
|
const EDIT_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
|
||||||
|
moduleApi.header.addButton({
|
||||||
|
icon: SYNC_ICON,
|
||||||
|
title: 'Sync',
|
||||||
|
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||||
|
});
|
||||||
|
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
||||||
|
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||||
|
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||||
|
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
const bumpCols = (d) => {
|
||||||
|
const max = Math.max(1, state && state.slots ? state.slots.length : 12);
|
||||||
|
const next = Math.max(1, Math.min(max, resolveCols() + d));
|
||||||
|
moduleApi.saveInstanceSettings({ columns: next });
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
moduleApi.header.addButton({ icon: MINUS_ICON, title: 'Fewer columns', onClick() { bumpCols(-1); } });
|
||||||
|
moduleApi.header.addButton({ icon: PLUS_ICON, title: 'More columns', onClick() { bumpCols(1); } });
|
||||||
|
const CHOIR_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V5l11-2v13"/><path d="M8.5 8.5l11-2"/></svg>';
|
||||||
|
choirBtn = moduleApi.header.addButton({ icon: CHOIR_ICON, title: 'Choir', onClick() { api('/choir', { method: 'POST' }); } });
|
||||||
|
moduleApi.fetch('/state').then((r) => r.json()).then((d) => { state = d.state; render(); }).catch(() => {});
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
error() { reportStatus(); },
|
||||||
|
events: {
|
||||||
|
state(e) {
|
||||||
|
try { state = JSON.parse(e.data); } catch { return; }
|
||||||
|
// While editing, don't yank a rename prompt out from under the user:
|
||||||
|
// re-render the editor only (state carries board + library).
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onResize() {},
|
||||||
|
stop() {
|
||||||
|
if (stream) { stream.close(); stream = null; }
|
||||||
|
stopConfetti();
|
||||||
|
stopParallax();
|
||||||
|
stopDragAutoScroll();
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slot ↔ Planning Center position matching for the Band Lineup module.
|
||||||
|
*
|
||||||
|
* A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g.
|
||||||
|
* "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO
|
||||||
|
* team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign
|
||||||
|
* people to slots by loose token overlap — the same logic the backstage
|
||||||
|
* Electron app used, lifted here unchanged so the board matches what the booth
|
||||||
|
* is used to. Pure functions only: no I/O, so this file is unit-testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function normalizeTeamName(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */
|
||||||
|
function isAllowedTeamName(value, keywords) {
|
||||||
|
const normalized = normalizeTeamName(value);
|
||||||
|
if (!normalized) return false;
|
||||||
|
return (keywords || []).some((kw) => {
|
||||||
|
const k = normalizeTeamName(kw);
|
||||||
|
return k && (normalized === k || normalized.includes(k));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */
|
||||||
|
function normalizeToken(token) {
|
||||||
|
const normalized = String(token || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return '';
|
||||||
|
if (/^\d+$/.test(normalized)) return '';
|
||||||
|
if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return '';
|
||||||
|
if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
|
||||||
|
if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
|
||||||
|
if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
|
||||||
|
if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
|
||||||
|
if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
|
||||||
|
if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
|
||||||
|
if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
|
||||||
|
if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
|
||||||
|
if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
|
||||||
|
if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelTokens(value) {
|
||||||
|
const normalized = normalizeLabel(value);
|
||||||
|
if (!normalized) return [];
|
||||||
|
const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean);
|
||||||
|
if (!tokens.length && normalized) return [normalized];
|
||||||
|
return [...new Set(tokens)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role part of a slot label to match on: the bracketed content when present
|
||||||
|
* ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This
|
||||||
|
* drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't
|
||||||
|
* be mistaken for a position number.
|
||||||
|
*/
|
||||||
|
function slotRoleText(label) {
|
||||||
|
const brackets = String(label || '').match(/\[([^\]]*)\]/g);
|
||||||
|
if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', ');
|
||||||
|
return String(label || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The set of standalone numbers in a label ("vox 2" → {"2"}). */
|
||||||
|
function numbersIn(value) {
|
||||||
|
return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */
|
||||||
|
function scoreMatch(slotLabel, positionLabel) {
|
||||||
|
const slotNormalized = normalizeLabel(slotLabel);
|
||||||
|
const positionNormalized = normalizeLabel(positionLabel);
|
||||||
|
if (!slotNormalized || !positionNormalized) return 0;
|
||||||
|
// A numbered role only matches the same number: "Vox 3" must not take "Vox 2".
|
||||||
|
const slotNums = numbersIn(slotLabel);
|
||||||
|
const posNums = numbersIn(positionLabel);
|
||||||
|
if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0;
|
||||||
|
if (slotNormalized === positionNormalized) return 120;
|
||||||
|
if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
|
||||||
|
|
||||||
|
const slotTokens = labelTokens(slotLabel);
|
||||||
|
const positionTokens = labelTokens(positionLabel);
|
||||||
|
if (!slotTokens.length || !positionTokens.length) return 0;
|
||||||
|
|
||||||
|
const positionSet = new Set(positionTokens);
|
||||||
|
const slotSet = new Set(slotTokens);
|
||||||
|
const overlap = slotTokens.filter((t) => positionSet.has(t));
|
||||||
|
if (!overlap.length) return 0;
|
||||||
|
|
||||||
|
const overlapCount = overlap.length;
|
||||||
|
const slotCoverage = overlapCount / slotSet.size;
|
||||||
|
const positionCoverage = overlapCount / positionSet.size;
|
||||||
|
const sharedPhrase = overlap.join(' ');
|
||||||
|
|
||||||
|
let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
|
||||||
|
if (slotCoverage === 1) score += 18;
|
||||||
|
if (positionCoverage === 1) score += 10;
|
||||||
|
if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign scheduled members to the ordered slot list. Greedy by best score, each
|
||||||
|
* member used once. Returns an array aligned to `slots`, each entry the chosen
|
||||||
|
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||||
|
*/
|
||||||
|
const MIN_SCORE = 60;
|
||||||
|
function assignMembersToSlots(slots, members) {
|
||||||
|
const pool = members.slice();
|
||||||
|
return slots.map((slotLabel) => {
|
||||||
|
const role = slotRoleText(slotLabel);
|
||||||
|
let bestIndex = -1;
|
||||||
|
let bestScore = MIN_SCORE - 1;
|
||||||
|
for (let i = 0; i < pool.length; i += 1) {
|
||||||
|
const score = scoreMatch(role, pool[i].label);
|
||||||
|
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||||
|
}
|
||||||
|
if (bestIndex < 0) return null;
|
||||||
|
return pool.splice(bestIndex, 1)[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||||
|
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||||
|
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||||||
|
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||||
|
* `entries` is [{ ...whatever, name }].
|
||||||
|
*/
|
||||||
|
function bestLibraryMatch(pcoName, entries) {
|
||||||
|
const target = normalizeLabel(pcoName);
|
||||||
|
if (!target) return null;
|
||||||
|
const T = target.split(' ').filter(Boolean);
|
||||||
|
const scoreOf = (name) => {
|
||||||
|
const ln = normalizeLabel(name);
|
||||||
|
if (!ln) return 0;
|
||||||
|
if (ln === target) return 100; // exact full name
|
||||||
|
const L = ln.split(' ').filter(Boolean);
|
||||||
|
if (!L.length || !T.length) return 0;
|
||||||
|
// Align the library name against a leading run of the person's name, token by token.
|
||||||
|
// Every library token must equal the person's token in that position — except the LAST
|
||||||
|
// library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M"
|
||||||
|
// match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni".
|
||||||
|
let ok = true; let exact = 0; let usedPrefix = false;
|
||||||
|
for (let k = 0; k < L.length; k += 1) {
|
||||||
|
const lt = L[k]; const tt = T[k];
|
||||||
|
if (!tt) { ok = false; break; }
|
||||||
|
if (lt === tt) { exact += 1; continue; }
|
||||||
|
if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; }
|
||||||
|
ok = false; break;
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan"
|
||||||
|
if (exact === 1) return 90; // first name matches exactly ("alex")
|
||||||
|
if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous
|
||||||
|
}
|
||||||
|
// Otherwise: a library surname that appears anywhere in the person's name.
|
||||||
|
const last = L[L.length - 1];
|
||||||
|
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let best = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
let tie = false;
|
||||||
|
for (const e of entries) {
|
||||||
|
const s = scoreOf(e.name);
|
||||||
|
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||||
|
else if (s === bestScore && s > 0) tie = true;
|
||||||
|
}
|
||||||
|
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeTeamName,
|
||||||
|
isAllowedTeamName,
|
||||||
|
bestLibraryMatch,
|
||||||
|
normalizeLabel,
|
||||||
|
normalizeToken,
|
||||||
|
labelTokens,
|
||||||
|
slotRoleText,
|
||||||
|
numbersIn,
|
||||||
|
scoreMatch,
|
||||||
|
assignMembersToSlots,
|
||||||
|
MIN_SCORE,
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert = require('assert');
|
||||||
|
const { assignMembersToSlots, isAllowedTeamName, slotRoleText, scoreMatch } = require('./matching');
|
||||||
|
|
||||||
|
const SLOTS = [
|
||||||
|
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4',
|
||||||
|
'[GUITAR, LEAD] MIX 5', '[BASS] MIX 6', '[VOX 5] MIX 7', '[PIANO]', '[DRUMS]',
|
||||||
|
'[KEYS 2]', '[CALL TO WORSHIP] MC2', 'STAGE MANAGER', '[MC] MC1', '[SPEAKER] HEADSET',
|
||||||
|
];
|
||||||
|
const M = (label) => ({ label, name: label });
|
||||||
|
const members = [
|
||||||
|
M('Vox 3'), M('Vox 2'), M('Vox 1'), M('Guitar / Lead'), M('Bass'), M('Piano'), M('Drums'), M('MC'), M('Keys 2'),
|
||||||
|
];
|
||||||
|
const out = assignMembersToSlots(SLOTS, members).map((m) => (m ? m.label : null));
|
||||||
|
|
||||||
|
// Numbered vocals land in the RIGHT slot (this is the swap bug we fixed).
|
||||||
|
assert.strictEqual(out[0], 'Vox 1', 'slot 0 → Vox 1');
|
||||||
|
assert.strictEqual(out[1], 'Vox 2', 'slot 1 → Vox 2');
|
||||||
|
assert.strictEqual(out[2], 'Vox 3', 'slot 2 → Vox 3');
|
||||||
|
assert.strictEqual(out[3], null, 'slot 3 (Vox 4) unfilled — nobody scheduled');
|
||||||
|
assert.strictEqual(out[4], 'Guitar / Lead', 'slot 4 → Guitar/Lead');
|
||||||
|
assert.strictEqual(out[5], 'Bass', 'slot 5 → Bass');
|
||||||
|
assert.strictEqual(out[6], null, 'slot 6 (Vox 5) unfilled');
|
||||||
|
assert.strictEqual(out[7], 'Piano', 'slot 7 → Piano');
|
||||||
|
assert.strictEqual(out[8], 'Drums', 'slot 8 → Drums');
|
||||||
|
assert.strictEqual(out[9], 'Keys 2', 'slot 9 → Keys 2');
|
||||||
|
assert.strictEqual(out[12], 'MC', 'slot 12 → MC');
|
||||||
|
|
||||||
|
// A different number never matches.
|
||||||
|
assert.strictEqual(scoreMatch(slotRoleText('[VOX 2] MIX 2'), 'Vox 3'), 0, 'Vox 3 must not match Vox 2 slot');
|
||||||
|
// Mic number no longer pollutes: Vox 7 must not grab "[VOX 5] MIX 7".
|
||||||
|
assert.strictEqual(scoreMatch(slotRoleText('[VOX 5] MIX 7'), 'Vox 7'), 0, 'Vox 7 must not match the MIX 7 slot');
|
||||||
|
|
||||||
|
// Team allow-list (singular stems match plurals/variants via substring).
|
||||||
|
assert.ok(isAllowedTeamName('Band', ['band', 'vocal']));
|
||||||
|
assert.ok(isAllowedTeamName('Vocals', ['band', 'vocal']));
|
||||||
|
assert.ok(isAllowedTeamName('Vocal Team', ['band', 'vocal']));
|
||||||
|
assert.ok(!isAllowedTeamName('Video', ['band', 'vocal', 'production']));
|
||||||
|
|
||||||
|
console.log('matching.test.js: all assertions passed ✓');
|
||||||
|
console.log('assignment:', JSON.stringify(out));
|
||||||
|
|
||||||
|
// --- bestLibraryMatch: folder-photo → PCO person by name ---
|
||||||
|
{
|
||||||
|
const { bestLibraryMatch } = require('./matching');
|
||||||
|
const name = (n) => (e) => (e ? e.name : null);
|
||||||
|
const lib = [
|
||||||
|
{ id: 'a', name: 'Alex' }, { id: 'g', name: 'AlexG' }, { id: 'bm', name: 'Brian M' },
|
||||||
|
{ id: 'c', name: 'Courtney' }, { id: 'gn', name: 'Gaitan' }, { id: 'wes', name: 'Wes' },
|
||||||
|
];
|
||||||
|
const pick = (n, list = lib) => { const e = bestLibraryMatch(n, list); return e ? e.name : null; };
|
||||||
|
assert.strictEqual(pick('Alex Gaitan'), 'Alex', 'first name matches "Alex"');
|
||||||
|
assert.strictEqual(pick('Brian Moniz'), 'Brian M', '"Brian M" matches "Brian Moniz"');
|
||||||
|
assert.strictEqual(pick('Courtney Maranda'), 'Courtney', 'first name matches');
|
||||||
|
assert.strictEqual(pick('Alexander Zinni'), null, '"Alex" must NOT grab "Alexander Zinni"');
|
||||||
|
assert.strictEqual(pick('Wes Carter'), 'Wes', 'first name matches "Wes"');
|
||||||
|
// surname-named photo
|
||||||
|
assert.strictEqual(pick('Someone Gaitan'), 'Gaitan', 'surname photo matches by last name');
|
||||||
|
// two people share a first name + only a first-name photo → ambiguous only if two matches tie;
|
||||||
|
// here a single "Alex" photo still maps to whoever's first name is exactly Alex.
|
||||||
|
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||||
|
// exact full name beats a first-name photo
|
||||||
|
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||||
|
// tie → no match (two identical-scoring photos)
|
||||||
|
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||||
|
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{
|
||||||
|
"id": "band-lineup",
|
||||||
|
"name": "Band Lineup",
|
||||||
|
"version": "1.11.0",
|
||||||
|
"proddash": ">=1.4.0",
|
||||||
|
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 3
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 8,
|
||||||
|
"h": 5
|
||||||
|
},
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Reading photos and birthdays needs People access on the token. Saved secrets never reach browsers."
|
||||||
|
},
|
||||||
|
"Stage": {
|
||||||
|
"help": "One mic/pack label per line, in the order they sit on stage. Scheduled people are matched into these by their Planning Center position (e.g. a line '[VOX 2] MIX 2' takes whoever is scheduled on Vox 2). Blank lines are empty spots."
|
||||||
|
},
|
||||||
|
"Photo library": {
|
||||||
|
"help": "Paste a Google Drive folder share link (set the folder to 'Anyone with the link → Viewer'). Every image is imported (file name → person's name) and kept in sync — add or remove photos in Drive and the board follows. A local folder path also works. If Drive can't be read, the board falls back to Planning Center photos."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"appId": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Client ID",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "Secret",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
|
||||||
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
|
},
|
||||||
|
"serviceTypeId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Service type",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/service-types"
|
||||||
|
},
|
||||||
|
"planId": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Plan",
|
||||||
|
"default": "",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "",
|
||||||
|
"label": "Automatic — today's plan, else the next upcoming"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"optionsRoute": "/plans",
|
||||||
|
"optionsDependsOn": "serviceTypeId"
|
||||||
|
},
|
||||||
|
"teams": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Teams to include",
|
||||||
|
"default": "Band\nWorship\nVocal\nProduction\nCommunication",
|
||||||
|
"help": "One team name (or keyword) per line. A person on any matching Planning Center team is eligible for the board."
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Stage slots",
|
||||||
|
"default": "[LEADER, VOX 1, ACOUSTIC] MIX 1\n[VOX 2] MIX 2\n[VOX 3] MIX 3\n[VOX 4] MIX 4\n[GUITAR, LEAD] MIX 5\n[BASS] MIX 6\n[VOX 5] MIX 7\n[PIANO]\n[DRUMS]\n[KEYS 2]\n[CALL TO WORSHIP] MC2\nSTAGE MANAGER\n[MC] MC1\n[SPEAKER] HEADSET",
|
||||||
|
"group": "Stage"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Columns",
|
||||||
|
"default": 7,
|
||||||
|
"group": "Stage",
|
||||||
|
"help": "Default slots per row (each screen can still adjust with the − / + buttons in its title bar)."
|
||||||
|
},
|
||||||
|
"photoFolder": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Google Drive folder link",
|
||||||
|
"default": "",
|
||||||
|
"group": "Photo library",
|
||||||
|
"help": "e.g. https://drive.google.com/drive/folders/… (a local folder path also works). Leave blank to use Planning Center photos only."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Birthday": {
|
||||||
|
"toggle": "birthdays"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showNames": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Names",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showLabels": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Mic labels",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"parallax": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Parallax motion",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"birthdays": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Birthday confetti",
|
||||||
|
"default": true,
|
||||||
|
"group": "Birthday"
|
||||||
|
},
|
||||||
|
"birthdayIntensity": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Intensity",
|
||||||
|
"default": "subtle",
|
||||||
|
"group": "Birthday",
|
||||||
|
"showWhen": "birthdays",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "subtle",
|
||||||
|
"label": "Subtle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "full",
|
||||||
|
"label": "Full"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"birthdayTest": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Force on (test)",
|
||||||
|
"default": "",
|
||||||
|
"group": "Birthday",
|
||||||
|
"showWhen": "birthdays",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "",
|
||||||
|
"label": "Off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"optionsRoute": "/people"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Band Lineup — server part.
|
||||||
|
*
|
||||||
|
* Owns the shared stage board and the uploaded-photo library, plus the one
|
||||||
|
* Planning Center connection. The board is server-side and global: whoever
|
||||||
|
* edits it (drag a library photo onto a slot, rename, clear) changes it for
|
||||||
|
* every Band Lineup tile on every screen, over SSE. Planning Center sync is an
|
||||||
|
* optional auto-fill — it only fills slots nobody has placed by hand. Browsers
|
||||||
|
* never talk to Planning Center directly.
|
||||||
|
*
|
||||||
|
* GET /state → { state } (board + library; instant paint)
|
||||||
|
* GET /stream → SSE `state` events (change-detected, heartbeat)
|
||||||
|
* GET /library → { people } (the drag-in photo library)
|
||||||
|
* GET /service-types → { options } (admin "Service type" select)
|
||||||
|
* GET /plans → { options } (admin "Plan" select)
|
||||||
|
* GET /people → { options } (gear "Force birthday" select)
|
||||||
|
* GET /photo/<file> → image bytes (library + cached PCO photos)
|
||||||
|
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
|
||||||
|
* POST /library/<id>/delete→ remove from the library and any slot
|
||||||
|
* POST /assign → ?slot=&personId= place a library person in a slot
|
||||||
|
* POST /move → ?from=&to= move or swap one slot's occupant
|
||||||
|
* POST /slot/<index>/name → ?name= rename the occupant (or make a name-only spot)
|
||||||
|
* POST /slot/<index>/clear → empty the slot
|
||||||
|
* POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
|
||||||
|
* POST /sync → run a Planning Center + photo-folder sync now
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||||
|
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||||
|
const SERVICES = '/services/v2';
|
||||||
|
const PEOPLE = '/people/v2';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 12000;
|
||||||
|
const PHOTO_TIMEOUT_MS = 15000;
|
||||||
|
const MIN_POLL_S = 10;
|
||||||
|
const RETRY_MS = 15000;
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
const MODULE_ID = 'band-lineup';
|
||||||
|
const MAX_UPLOAD = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
let current = null; // set by init(), read by routes; rebuilt together on remount
|
||||||
|
|
||||||
|
/* ── data directory (library + board + photo cache live here, never in the repo) ── */
|
||||||
|
|
||||||
|
function resolveDataDir() {
|
||||||
|
const home = os.homedir();
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
if (fromEnv) return path.resolve(root, fromEnv);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── small helpers ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
|
||||||
|
function nonEmptyLines(text) { return lines(text).filter(Boolean); }
|
||||||
|
function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
|
||||||
|
function birthdayMmdd(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
||||||
|
if (full) return `${full[1]}-${full[2]}`;
|
||||||
|
return /^\d{2}-\d{2}$/.test(s) ? s : '';
|
||||||
|
}
|
||||||
|
function birthdayWeekSet(now = new Date()) {
|
||||||
|
const daysSinceWed = (now.getDay() - 3 + 7) % 7;
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||||||
|
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')}`);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
function extFromContentType(ct) {
|
||||||
|
const t = String(ct || '').toLowerCase();
|
||||||
|
if (t.includes('png')) return 'png';
|
||||||
|
if (t.includes('webp')) return 'webp';
|
||||||
|
if (t.includes('gif')) return 'gif';
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
function contentTypeForFile(file) {
|
||||||
|
const e = path.extname(file).toLowerCase();
|
||||||
|
if (e === '.png') return 'image/png';
|
||||||
|
if (e === '.webp') return 'image/webp';
|
||||||
|
if (e === '.gif') return 'image/gif';
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
function json(res, status, body) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
|
||||||
|
function newId() { return crypto.randomBytes(6).toString('hex'); }
|
||||||
|
function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
|
||||||
|
|
||||||
|
/* ── Planning Center client ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function createPcoClient(config) {
|
||||||
|
const token = String(config.accessToken || '').trim();
|
||||||
|
const appId = String(config.appId || '').trim();
|
||||||
|
const secret = String(config.secret || '');
|
||||||
|
let auth = '';
|
||||||
|
if (token) auth = `Bearer ${token}`;
|
||||||
|
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||||||
|
const configured = Boolean(auth);
|
||||||
|
|
||||||
|
async function get(target, params) {
|
||||||
|
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||||||
|
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(`Planning Center unreachable: ${err?.message || err}`); e.network = true; throw e;
|
||||||
|
}
|
||||||
|
if (!response.ok) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
async function getAll(target, params) {
|
||||||
|
const data = []; const included = []; let next = null;
|
||||||
|
let query = { per_page: 100, ...(params || {}) }; let guard = 0;
|
||||||
|
do {
|
||||||
|
const body = next ? await get(next) : await get(target, query);
|
||||||
|
if (Array.isArray(body.data)) data.push(...body.data);
|
||||||
|
if (Array.isArray(body.included)) included.push(...body.included);
|
||||||
|
next = body?.links?.next || null; query = null; guard += 1;
|
||||||
|
} while (next && guard < 50);
|
||||||
|
return { data, included };
|
||||||
|
}
|
||||||
|
return { configured, get, getAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
|
||||||
|
|
||||||
|
const DRIVE_TIMEOUT_MS = 30000;
|
||||||
|
const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
|
||||||
|
|
||||||
|
/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
|
||||||
|
function parseDriveFolderId(s) {
|
||||||
|
const str = String(s || '');
|
||||||
|
if (!/drive\.google\.com/i.test(str)) return '';
|
||||||
|
const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
|
||||||
|
async function driveList(folderId) {
|
||||||
|
const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
|
||||||
|
headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
|
||||||
|
const html = await resp.text();
|
||||||
|
const out = []; const seen = new Set();
|
||||||
|
const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+)</g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html))) {
|
||||||
|
const id = m[1]; const name = m[2].trim();
|
||||||
|
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
|
||||||
|
async function driveFetchImage(fileId) {
|
||||||
|
const base = 'https://drive.usercontent.google.com/download';
|
||||||
|
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
|
||||||
|
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
|
||||||
|
let ct = String(resp.headers.get('content-type') || '');
|
||||||
|
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||||||
|
// Large-file virus-scan interstitial: grab the confirm token and retry.
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const html = await resp.text();
|
||||||
|
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
|
||||||
|
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
|
||||||
|
if (conf) {
|
||||||
|
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
|
||||||
|
ct = String(resp.headers.get('content-type') || '');
|
||||||
|
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
|
||||||
|
const tct = String(th.headers.get('content-type') || '');
|
||||||
|
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
|
||||||
|
throw new Error('not an image');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the module ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const dataDir = path.join(resolveDataDir(), MODULE_ID);
|
||||||
|
const photoDir = path.join(dataDir, 'photos');
|
||||||
|
const libraryPath = path.join(dataDir, 'library.json');
|
||||||
|
const boardPath = path.join(dataDir, 'board.json');
|
||||||
|
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
|
||||||
|
|
||||||
|
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
|
||||||
|
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
|
||||||
|
const columns = Math.max(1, Number(config.columns) || 7);
|
||||||
|
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||||||
|
const planIdCfg = String(config.planId || '').trim();
|
||||||
|
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
|
||||||
|
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
|
||||||
|
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
|
||||||
|
const settingsPath = path.join(dataDir, 'settings.json');
|
||||||
|
const settings = readJsonFile(settingsPath, {}) || {};
|
||||||
|
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||||||
|
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||||
|
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||||
|
|
||||||
|
const CHOIR_LABEL = 'CHOIR';
|
||||||
|
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
|
||||||
|
// so its assignment persists in board.json across toggles.
|
||||||
|
const effectiveSlots = () => (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
|
||||||
|
|
||||||
|
const pco = createPcoClient(config);
|
||||||
|
|
||||||
|
// Persisted, shared editor state.
|
||||||
|
let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
|
||||||
|
let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
|
||||||
|
function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
|
||||||
|
function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
|
||||||
|
|
||||||
|
let latest = clearedState();
|
||||||
|
let lastFrame = '';
|
||||||
|
let celebrateTimer = null;
|
||||||
|
let drivePollTimer = null;
|
||||||
|
let watcher = null;
|
||||||
|
let watchDebounce = null;
|
||||||
|
let stopped = false;
|
||||||
|
let syncing = false;
|
||||||
|
|
||||||
|
function clearedState() {
|
||||||
|
return {
|
||||||
|
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||||
|
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||||
|
plan: null,
|
||||||
|
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||||
|
library: [], choir: choirOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function libraryList() {
|
||||||
|
return Object.entries(library)
|
||||||
|
.map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast() {
|
||||||
|
if (!streams.size) return;
|
||||||
|
let frame; try { frame = JSON.stringify(latest); } catch { return; }
|
||||||
|
if (frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||||
|
for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPlans(stId) {
|
||||||
|
const [future, past] = await Promise.all([
|
||||||
|
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
|
||||||
|
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
|
||||||
|
]);
|
||||||
|
const rows = [...(future.data || []), ...(past.data || [])];
|
||||||
|
return rows.map((rec) => ({
|
||||||
|
id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
|
||||||
|
title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
|
||||||
|
dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function pickPlan(plans) {
|
||||||
|
if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
|
||||||
|
const todayY = localYmd(new Date());
|
||||||
|
const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
|
||||||
|
if (today) return today;
|
||||||
|
const now = Date.now();
|
||||||
|
const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
|
||||||
|
return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cachePcoPhoto(personId, url) {
|
||||||
|
if (!url) return '';
|
||||||
|
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
|
||||||
|
const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
|
||||||
|
if (existing) return existing;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
|
||||||
|
if (!resp.ok) return '';
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
if (!buf.length) return '';
|
||||||
|
const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
|
||||||
|
fs.writeFileSync(path.join(photoDir, file), buf);
|
||||||
|
return file;
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a folder of photos into the library and keep it in sync: new files are added
|
||||||
|
* (filename → name), changed files refreshed, and files removed from the folder dropped
|
||||||
|
* from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
|
||||||
|
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||||||
|
*/
|
||||||
|
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||||||
|
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||||
|
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||||
|
function scanFolder() {
|
||||||
|
if (!photoFolder) return false;
|
||||||
|
let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
|
||||||
|
const files = names.filter((n) => IMG_RE.test(n));
|
||||||
|
const present = new Set(files);
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
|
||||||
|
let changed = false;
|
||||||
|
for (const f of files) {
|
||||||
|
let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
|
||||||
|
const id = byKey.get(f);
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
const v = library[id];
|
||||||
|
if (v.mtime === mtime && v.file) continue; // unchanged
|
||||||
|
const dest = `lib-${id}.${extOf(f)}`;
|
||||||
|
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||||||
|
if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||||
|
v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
|
||||||
|
} else {
|
||||||
|
const nid = newId();
|
||||||
|
const dest = `lib-${nid}.${extOf(f)}`;
|
||||||
|
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||||||
|
library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
|
||||||
|
}
|
||||||
|
for (const [id, v] of Object.entries(library)) {
|
||||||
|
if (v.folderKey && !present.has(v.folderKey)) {
|
||||||
|
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||||
|
delete library[id];
|
||||||
|
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
|
||||||
|
// (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
|
||||||
|
// where someone uploaded people by hand and then pointed at a folder of the same people.
|
||||||
|
const folderByName = new Map();
|
||||||
|
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
|
||||||
|
for (const [id, v] of Object.entries(library)) {
|
||||||
|
if (v.folderKey || !v.name) continue;
|
||||||
|
const keepId = folderByName.get(normName(v.name));
|
||||||
|
if (!keepId) continue;
|
||||||
|
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||||
|
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||||||
|
delete library[id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) { saveLibrary(); saveBoard(); }
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import a public Google Drive folder into the library (file name → name), keeping it in sync.
|
||||||
|
// Keyed by Drive file id (folderKey "drive:<id>"). On a list failure we keep the last-good library.
|
||||||
|
async function scanDrive(folderId) {
|
||||||
|
let files;
|
||||||
|
try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
|
||||||
|
files = files.filter((f) => IMG_RE.test(f.name));
|
||||||
|
const present = new Set(files.map((f) => `drive:${f.id}`));
|
||||||
|
const have = new Set();
|
||||||
|
for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
|
||||||
|
let changed = false;
|
||||||
|
for (const f of files) {
|
||||||
|
const key = `drive:${f.id}`;
|
||||||
|
if (have.has(key)) continue; // already imported (by Drive file id)
|
||||||
|
let img;
|
||||||
|
try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
|
||||||
|
const nid = newId();
|
||||||
|
const dest = `lib-${nid}.${img.ext}`;
|
||||||
|
try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
|
||||||
|
catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
|
||||||
|
}
|
||||||
|
for (const [id, v] of Object.entries(library)) {
|
||||||
|
if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
|
||||||
|
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||||
|
delete library[id];
|
||||||
|
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drive photo wins over a same-named hand-upload (repoint any slot to it).
|
||||||
|
const driveByName = new Map();
|
||||||
|
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
|
||||||
|
for (const [id, v] of Object.entries(library)) {
|
||||||
|
if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
|
||||||
|
const keepId = driveByName.get(normName(v.name));
|
||||||
|
if (!keepId) continue;
|
||||||
|
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||||||
|
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||||||
|
delete library[id]; changed = true;
|
||||||
|
}
|
||||||
|
if (changed) { saveLibrary(); saveBoard(); }
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the configured photo source — a Drive folder link, or a local folder path.
|
||||||
|
async function syncPhotos() {
|
||||||
|
const fid = parseDriveFolderId(photoFolder);
|
||||||
|
if (fid) return scanDrive(fid);
|
||||||
|
if (photoFolder) return scanFolder();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWatch() {
|
||||||
|
if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
|
||||||
|
if (!photoFolder) return;
|
||||||
|
try {
|
||||||
|
watcher = fs.watch(photoFolder, { persistent: false }, () => {
|
||||||
|
clearTimeout(watchDebounce);
|
||||||
|
watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
|
||||||
|
});
|
||||||
|
log(`watching photo folder ${photoFolder}`);
|
||||||
|
} catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
|
||||||
|
}
|
||||||
|
function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
|
||||||
|
|
||||||
|
// Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
|
||||||
|
function prunePcoPhotos(keep) {
|
||||||
|
let files; try { files = fs.readdirSync(photoDir); } catch { return; }
|
||||||
|
for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
// Resolve one board slot to a display person (or null). The board is the source of truth —
|
||||||
|
// there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
|
||||||
|
// entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
|
||||||
|
function resolveSlot(i, weekSet, keep) {
|
||||||
|
const e = board[String(i)];
|
||||||
|
if (!e) return null;
|
||||||
|
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||||
|
if (e.pco) {
|
||||||
|
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
||||||
|
manual = false; personId = e.pco.personId || '';
|
||||||
|
name = String(e.pco.name || '').trim();
|
||||||
|
file = e.pco.file || '';
|
||||||
|
birthday = e.pco.birthday || '';
|
||||||
|
} else if (e.personId) {
|
||||||
|
const lib = library[e.personId];
|
||||||
|
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||||
|
personId = e.personId;
|
||||||
|
name = String(e.name || lib.name || '').trim();
|
||||||
|
file = lib.file || ''; birthday = lib.birthday || '';
|
||||||
|
} else if (e.name) {
|
||||||
|
name = String(e.name).trim();
|
||||||
|
} else return null;
|
||||||
|
if (file) keep.add(file);
|
||||||
|
return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
|
||||||
|
// and on a slow timer so a birthday week rolling over lights up on its own.
|
||||||
|
function renderBoard() {
|
||||||
|
const weekSet = birthdayWeekSet();
|
||||||
|
const keep = new Set();
|
||||||
|
const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
|
||||||
|
for (const v of Object.values(library)) if (v.file) keep.add(v.file);
|
||||||
|
prunePcoPhotos(keep);
|
||||||
|
latest = {
|
||||||
|
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||||
|
reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
|
||||||
|
columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildLocal() { renderBoard(); broadcast(); }
|
||||||
|
|
||||||
|
// Pull the current plan from Planning Center and write matched people INTO the board. Fills only
|
||||||
|
// slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
|
||||||
|
async function syncNow() {
|
||||||
|
if (syncing) return;
|
||||||
|
syncing = true;
|
||||||
|
try {
|
||||||
|
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||||
|
if (pco.configured && serviceTypeId) {
|
||||||
|
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||||
|
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||||
|
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||||
|
else {
|
||||||
|
|
||||||
|
const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
|
||||||
|
const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
|
||||||
|
const members = []; const seen = new Set();
|
||||||
|
for (const m of tm.data || []) {
|
||||||
|
const status = String(m.attributes?.status || '').trim();
|
||||||
|
if (status === 'D' || /^declined$/i.test(status)) continue;
|
||||||
|
const personId = String(m.relationships?.person?.data?.id || '');
|
||||||
|
const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
|
||||||
|
if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
|
||||||
|
const person = byKey.get(`Person:${personId}`);
|
||||||
|
members.push({
|
||||||
|
personId,
|
||||||
|
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||||
|
label: String(m.attributes?.team_position_name || '').trim(),
|
||||||
|
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||||
|
});
|
||||||
|
if (personId) seen.add(personId);
|
||||||
|
}
|
||||||
|
const bdays = new Map();
|
||||||
|
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||||
|
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||||
|
|
||||||
|
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||||
|
|
||||||
|
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||||
|
const eff = effectiveSlots();
|
||||||
|
const openIdx = []; const manualNames = new Set();
|
||||||
|
for (let i = 0; i < eff.length; i += 1) {
|
||||||
|
const e = board[String(i)];
|
||||||
|
if (e && (e.personId || e.name)) {
|
||||||
|
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||||
|
if (nm) manualNames.add(normName(nm));
|
||||||
|
} else openIdx.push(i);
|
||||||
|
}
|
||||||
|
const pool = members.filter((m) => !manualNames.has(normName(m.name)));
|
||||||
|
const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
|
||||||
|
|
||||||
|
for (let k = 0; k < openIdx.length; k += 1) {
|
||||||
|
const i = openIdx[k]; const m = assigned[k];
|
||||||
|
if (m) {
|
||||||
|
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||||
|
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||||
|
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||||
|
} else if (board[String(i)] && board[String(i)].pco) {
|
||||||
|
delete board[String(i)]; // was synced, no longer on the plan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveBoard();
|
||||||
|
latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
|
||||||
|
log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
|
||||||
|
} // end else (plan found)
|
||||||
|
} // end if (Planning Center configured)
|
||||||
|
} catch (err) {
|
||||||
|
latest.reachable = false; latest.lastError = err?.message || String(err);
|
||||||
|
log(`sync failed: ${err?.message || err}`);
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
renderBoard(); broadcast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
// Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
|
||||||
|
celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
|
||||||
|
celebrateTimer.unref?.();
|
||||||
|
// A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
|
||||||
|
// in Drive show up without a manual Sync.
|
||||||
|
drivePollTimer = setInterval(() => {
|
||||||
|
if (stopped || !parseDriveFolderId(photoFolder)) return;
|
||||||
|
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
drivePollTimer.unref?.();
|
||||||
|
|
||||||
|
if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
|
||||||
|
else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
|
||||||
|
rebuildLocal(); // paint the saved board immediately
|
||||||
|
startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
|
||||||
|
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
|
||||||
|
|
||||||
|
current = {
|
||||||
|
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||||
|
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||||
|
photoUrlFor, libraryList,
|
||||||
|
setChoir, choirOn: () => choirOn,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
|
||||||
|
if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
|
||||||
|
if (watchDebounce) clearTimeout(watchDebounce);
|
||||||
|
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||||||
|
streams.clear(); if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
|
||||||
|
if (latest.lastError) return { status: 'error', message: latest.lastError };
|
||||||
|
if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
|
||||||
|
return { status: 'ok', message: 'Ready — press Sync to load the plan' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes({ log }) {
|
||||||
|
// Read an image request body (raw bytes) with a size cap.
|
||||||
|
function readBody(req, cb) {
|
||||||
|
const chunks = []; let size = 0; let bad = false;
|
||||||
|
req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
|
||||||
|
req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
|
||||||
|
req.on('error', () => cb(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : 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.getLatest() : null)}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
current.streams.add(res);
|
||||||
|
req.on('close', () => current && current.streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
|
||||||
|
|
||||||
|
// Update a library entry: ?name=&birthday= and/or a replacement image body. /library/<id>/delete removes it.
|
||||||
|
'POST /library/*': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||||||
|
const del = /\/delete$/.test(rest);
|
||||||
|
const id = rest.replace(/\/delete$/, '').trim();
|
||||||
|
const entry = current.library[id];
|
||||||
|
if (!entry) return json(res, 404, { error: 'No such library photo.' });
|
||||||
|
if (del) {
|
||||||
|
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||||||
|
delete current.library[id];
|
||||||
|
for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
|
||||||
|
current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
const q = new URLSearchParams(req.search || '');
|
||||||
|
readBody(req, (buf) => {
|
||||||
|
if (q.has('name')) entry.name = String(q.get('name') || '').trim();
|
||||||
|
if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
|
||||||
|
if (buf && buf.length) {
|
||||||
|
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||||||
|
const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
|
||||||
|
try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
|
||||||
|
}
|
||||||
|
current.saveLibrary(); current.rebuildLocal();
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
|
||||||
|
'POST /choir': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const p = new URLSearchParams(req.search || '');
|
||||||
|
const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
|
||||||
|
current.setChoir(on);
|
||||||
|
json(res, 200, { ok: true, choir: on });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Move (or swap) one slot's occupant to another slot: ?from=<i>&to=<j>.
|
||||||
|
'POST /move': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const p = new URLSearchParams(req.search || '');
|
||||||
|
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||||
|
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||||
|
const a = current.board[from]; const b = current.board[to];
|
||||||
|
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||||
|
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||||
|
current.saveBoard(); current.rebuildLocal();
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Place a library person in a slot: ?slot=<index>&personId=<id> (empty personId clears the slot).
|
||||||
|
'POST /assign': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const q = new URLSearchParams(req.search || '');
|
||||||
|
const slot = String(q.get('slot') || '').trim();
|
||||||
|
const personId = String(q.get('personId') || '').trim();
|
||||||
|
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||||
|
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||||
|
if (personId) current.board[slot] = { personId, name: '' };
|
||||||
|
else delete current.board[slot];
|
||||||
|
current.saveBoard(); current.rebuildLocal();
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rename a slot's occupant, or make a name-only spot (no photo). /slot/<index>/clear empties it.
|
||||||
|
'POST /slot/*': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||||||
|
const clearing = /\/clear$/.test(rest);
|
||||||
|
const index = rest.replace(/\/(clear|name)$/, '').trim();
|
||||||
|
if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
|
||||||
|
if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
|
||||||
|
const q = new URLSearchParams(req.search || '');
|
||||||
|
const name = String(q.get('name') || '').trim();
|
||||||
|
const existing = current.board[index] || { personId: '', name: '' };
|
||||||
|
existing.name = name;
|
||||||
|
if (!existing.personId && !name) delete current.board[index];
|
||||||
|
else current.board[index] = existing;
|
||||||
|
current.saveBoard(); current.rebuildLocal();
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
|
||||||
|
// runs on its own timer; this just does it now.
|
||||||
|
'POST /sync': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /people': (req, res) => {
|
||||||
|
const state = current ? current.getLatest() : null;
|
||||||
|
const options = [{ value: '', label: 'Off' }];
|
||||||
|
for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
|
||||||
|
json(res, 200, { options });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /photo/*': (req, res) => {
|
||||||
|
if (!current) return void res.writeHead(404).end();
|
||||||
|
const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
|
||||||
|
if (!name || name.includes('..')) return void res.writeHead(400).end();
|
||||||
|
fs.readFile(path.join(current.photoDir, name), (err, buf) => {
|
||||||
|
if (err) return void res.writeHead(404).end();
|
||||||
|
res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
|
||||||
|
res.end(buf);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /service-types': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
|
||||||
|
if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||||||
|
try {
|
||||||
|
const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
|
||||||
|
const folders = new Map();
|
||||||
|
for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
|
||||||
|
const pathOf = (parentId) => { const names = []; let id = parentId; const seen = new Set(); while (id && folders.has(id) && !seen.has(id)) { seen.add(id); names.unshift(folders.get(id).name); id = folders.get(id).parentId; } return names.join(' › '); };
|
||||||
|
const options = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||||
|
json(res, 200, { options });
|
||||||
|
} catch (err) { log(`service type list failed: ${err?.message || err}`); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); }
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /plans': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { options: [] });
|
||||||
|
const params = new URLSearchParams(req.search || '');
|
||||||
|
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||||
|
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||||
|
if (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||||
|
try {
|
||||||
|
const plans = await current.listPlans(stId); const now = Date.now();
|
||||||
|
const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
|
||||||
|
json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
|
||||||
|
} catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/* Band Lineup — scoped to the tile, themed only through the shell's variables. */
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-board {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(var(--bl-rows, 2), minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-slot {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-photo {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--bg) 70%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center top;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.12s ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-initials {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.4em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-name {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
right: 6px;
|
||||||
|
max-width: calc(100% - 12px);
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.74em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.12;
|
||||||
|
z-index: 5;
|
||||||
|
/* Show the whole name — wrap onto more lines rather than clipping with an ellipsis. */
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-label {
|
||||||
|
padding: 5px 6px;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
min-height: 1.6em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-empty {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-empty .bl-label {
|
||||||
|
background: transparent;
|
||||||
|
border-top: none;
|
||||||
|
margin: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Birthday effects (confetti canvas + gold glow ring) ---- */
|
||||||
|
[data-module="band-lineup"] .bl-confetti {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-birthday-subtle {
|
||||||
|
animation: bl-glow-subtle 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-birthday-full {
|
||||||
|
animation: bl-glow-full 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes bl-glow-subtle {
|
||||||
|
0%, 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 14px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
50% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 20px color-mix(in srgb, var(--accent) 34%, transparent); }
|
||||||
|
}
|
||||||
|
@keyframes bl-glow-full {
|
||||||
|
0%, 100% { box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 80%, transparent), 0 0 20px color-mix(in srgb, var(--accent) 42%, transparent); }
|
||||||
|
50% { box-shadow: inset 0 0 0 3px var(--accent), 0 0 34px color-mix(in srgb, var(--accent) 66%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Editor (Edit button): board drop-targets + uploaded-photo library ---- */
|
||||||
|
[data-module="band-lineup"] .bl-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-btn.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||||
|
[data-module="band-lineup"] .bl-btn:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-editor {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-editor-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-editor-bar b { color: var(--text); }
|
||||||
|
[data-module="band-lineup"] .bl-editor-spacer { flex: 1; }
|
||||||
|
[data-module="band-lineup"] .bl-folder { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||||
|
[data-module="band-lineup"] .bl-folder-path { font-size: 0.72em; color: var(--muted); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
[data-module="band-lineup"] .bl-editor-body { flex: 1; min-height: 0; overflow: auto; padding: 10px; }
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-edit-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-edit-slot {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-edit-slot.bl-drop-over { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
|
[data-module="band-lineup"] .bl-edit-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
|
||||||
|
[data-module="band-lineup"] .bl-edit-photo .bl-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||||
|
[data-module="band-lineup"] .bl-edit-drop {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--muted); font-size: 1.6em; border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-edit-namerow { display: flex; align-items: center; gap: 2px; padding: 2px; }
|
||||||
|
[data-module="band-lineup"] .bl-edit-name {
|
||||||
|
flex: 1; min-width: 0; text-align: left; background: transparent; border: 0;
|
||||||
|
color: var(--text); font: inherit; font-size: 0.72em; font-weight: 600; padding: 3px 4px;
|
||||||
|
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-edit-name:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||||
|
[data-module="band-lineup"] .bl-edit-clear {
|
||||||
|
flex: 0 0 auto; width: 20px; height: 20px; border: 0; border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); cursor: pointer; font-size: 0.9em; line-height: 1;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-edit-label { padding: 3px 5px 5px; font-size: 0.64em; color: var(--muted); text-align: center; border-top: 1px solid var(--border); word-break: break-word; }
|
||||||
|
|
||||||
|
[data-module="band-lineup"] .bl-library {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-lib-empty { grid-column: 1 / -1; color: var(--muted); font-size: 0.85em; padding: 10px 2px; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-item {
|
||||||
|
position: relative; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
||||||
|
background: var(--panel); cursor: grab; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-lib-item:active { cursor: grabbing; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||||
|
[data-module="band-lineup"] .bl-lib-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; pointer-events: none; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-name {
|
||||||
|
border: 0; background: transparent; color: var(--text); font: inherit; font-size: 0.68em; font-weight: 600;
|
||||||
|
padding: 3px 4px; text-align: center; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-lib-del {
|
||||||
|
position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; border: 0; border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Title matching for the PCO Plan module — the one answer to "is this
|
* Title matching for the PCO Plan module — the one answer to "is this
|
||||||
* ProPresenter document that Planning Center thing?", used twice:
|
* ProPresenter document that Planning Center thing?", used three ways:
|
||||||
*
|
*
|
||||||
* - mapping the document ProPresenter has live to a plan item (the
|
* - mapping the document ProPresenter has live to a plan item (the
|
||||||
* highlight and the item runtimes), and
|
* highlight and the item runtimes) — placeDocument(), below,
|
||||||
|
* - recognising the message document, which is named after the sermon
|
||||||
|
* and never after its plan item — the message rule, below, and
|
||||||
* - recognising the documents an admin picked as the service start / end
|
* - recognising the documents an admin picked as the service start / end
|
||||||
* cues (see service-timing.js).
|
* cues (see service-timing.js).
|
||||||
*
|
*
|
||||||
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
|
|||||||
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
|
/* ── the message rule ──────────────────────────────────────────────────
|
||||||
|
A sermon's ProPresenter document is named after the sermon, never after
|
||||||
|
the plan item that stands for it ("Message", "Resi Message"), so no name
|
||||||
|
comparison can place it — and the previous song stayed "current" through
|
||||||
|
a whole message. What the document's name does carry is the plan's own
|
||||||
|
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
|
||||||
|
date, its series title and its title. A live document that reads like
|
||||||
|
the plan is the plan's message, and lands on the plan's message item,
|
||||||
|
however that item happens to be titled. */
|
||||||
|
|
||||||
|
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
|
||||||
|
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
|
||||||
|
/** Undated, the name must read like "Series Title Part #" at least this closely… */
|
||||||
|
const MESSAGE_THRESHOLD = 0.75;
|
||||||
|
/** …dated with this plan's date, it need only resemble the series or the title. */
|
||||||
|
const DATED_MESSAGE_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
|
||||||
|
function leadingDateCode(raw) {
|
||||||
|
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
const code = m[1];
|
||||||
|
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
|
||||||
|
const mo = Number(code.slice(-4, -2));
|
||||||
|
const d = Number(code.slice(-2));
|
||||||
|
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||||
|
return { y, m: mo, d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The local calendar day of an epoch-ms instant, in the same shape. */
|
||||||
|
function dayOf(ms) {
|
||||||
|
const t = new Date(Number(ms));
|
||||||
|
if (!Number.isFinite(t.getTime())) return null;
|
||||||
|
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(a, b) {
|
||||||
|
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this live document read like the plan itself — "260927 Where is Your
|
||||||
|
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
|
||||||
|
* 27 Sept 2026? Returns { score, dated } or null.
|
||||||
|
*
|
||||||
|
* A date code naming another day is a firm no: last week's sermon is not
|
||||||
|
* this plan's message. A date code naming the plan's day is strong evidence,
|
||||||
|
* so the rest need only resemble the series or the title. Without a date
|
||||||
|
* the name must read like series + title together — and carry the title
|
||||||
|
* when the plan has both, so a series bumper ("Where is Your Faith?") is
|
||||||
|
* not taken for the sermon.
|
||||||
|
*/
|
||||||
|
function messageDocumentScore(name, plan) {
|
||||||
|
const series = String(plan?.seriesTitle || '').trim();
|
||||||
|
const title = String(plan?.title || '').trim();
|
||||||
|
if (!series && !title) return null;
|
||||||
|
const code = leadingDateCode(name);
|
||||||
|
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
|
||||||
|
if (code && planDay && !sameDay(code, planDay)) return null;
|
||||||
|
const dated = Boolean(code && planDay);
|
||||||
|
const full = [series, title].filter(Boolean).join(' ');
|
||||||
|
const fullScore = similarity(name, full);
|
||||||
|
if (dated) {
|
||||||
|
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
|
||||||
|
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
|
||||||
|
}
|
||||||
|
if (fullScore < MESSAGE_THRESHOLD) return null;
|
||||||
|
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
|
||||||
|
return { score: fullScore, dated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan item that stands for the message: an item titled with the word
|
||||||
|
* ("Message", "Resi Message" — a bare "Message" first), else the first item
|
||||||
|
* under a header so titled (a "Sermon" section whose one item is the sermon's
|
||||||
|
* own title). Null when the plan has no such slot.
|
||||||
|
*/
|
||||||
|
function messageItemOf(items) {
|
||||||
|
let best = null;
|
||||||
|
let inSection = false;
|
||||||
|
for (const it of Array.isArray(items) ? items : []) {
|
||||||
|
if (!it) continue;
|
||||||
|
const title = String(it.title || '');
|
||||||
|
if (it.itemType === 'header') {
|
||||||
|
inSection = MESSAGE_WORDS.test(title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
|
||||||
|
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
|
||||||
|
if (rank === 3) break;
|
||||||
|
}
|
||||||
|
return best ? best.item : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
|
||||||
|
* builds a Planning Center playlist that way — every plan item, headers
|
||||||
|
* included, each flagged as PCO — until an operator adds something of their
|
||||||
|
* own (a song tag, say), which shifts every position after it. Same length,
|
||||||
|
* headers in the same places and every item PCO-flagged is the test.
|
||||||
|
*/
|
||||||
|
function playlistMirrorsPlan(ppItems, items) {
|
||||||
|
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
|
||||||
|
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best plan item by title, the ones ahead of the current item winning ties. */
|
||||||
|
function bestByName(name, items, all, ctx) {
|
||||||
|
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
|
||||||
|
const hint = ctx?.hint;
|
||||||
|
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
|
||||||
|
let best = null;
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
||||||
|
if (score < MATCH_THRESHOLD) continue;
|
||||||
|
// Distance ahead of the current item (wrapping so items behind rank last).
|
||||||
|
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
||||||
|
const atPosition = Boolean(wanted && item.id === wanted.id);
|
||||||
|
const candidate = { item, score, dist, atPosition };
|
||||||
|
if (!best || score > best.score + 0.001) {
|
||||||
|
best = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.abs(score - best.score) > 0.001) continue;
|
||||||
|
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
|
||||||
|
}
|
||||||
|
return best ? { item: best.item, score: best.score, via: 'name' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a live ProPresenter document sits in the plan: { item, score, via }
|
||||||
|
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
|
||||||
|
* is { index, mirror } — the document's position in the ProPresenter
|
||||||
|
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
|
||||||
|
*
|
||||||
|
* By name first: the item (or song) title most like the document's name,
|
||||||
|
* the ones ahead of the current item winning ties — a near miss lands on the
|
||||||
|
* right row. Then the message rule: a document that reads like the plan
|
||||||
|
* itself lands on the plan's message item. It overrides a name hit that is
|
||||||
|
* anything short of identical — a song called "Faith" must not catch
|
||||||
|
* "260927 Where is Your Faith? - Part 6" — outright when the document is
|
||||||
|
* dated with the plan's day, and when it reads better otherwise. Position
|
||||||
|
* is never a match on its own: it only breaks a tie between equally good
|
||||||
|
* names, and only while the playlist mirrors the plan.
|
||||||
|
*/
|
||||||
|
function placeDocument(name, ctx) {
|
||||||
|
const all = Array.isArray(ctx?.items) ? ctx.items : [];
|
||||||
|
const items = all.filter((it) => it && it.itemType !== 'header');
|
||||||
|
if (!items.length) return null;
|
||||||
|
const byName = bestByName(name, items, all, ctx);
|
||||||
|
const asMessage = messageDocumentScore(name, ctx?.plan);
|
||||||
|
const messageItem = asMessage ? messageItemOf(all) : null;
|
||||||
|
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
|
||||||
|
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
|
||||||
|
}
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MATCH_THRESHOLD,
|
||||||
|
TRIGGER_THRESHOLD,
|
||||||
|
MESSAGE_THRESHOLD,
|
||||||
|
cleanTitle,
|
||||||
|
normalize,
|
||||||
|
similarity,
|
||||||
|
isCue,
|
||||||
|
leadingDateCode,
|
||||||
|
messageDocumentScore,
|
||||||
|
messageItemOf,
|
||||||
|
playlistMirrorsPlan,
|
||||||
|
placeDocument,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the PCO Plan module's document matching — placing what
|
||||||
|
* ProPresenter has live onto a plan item, and the message rule.
|
||||||
|
*
|
||||||
|
* node modules/pco-plan/matching.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies; exits non-zero on the first failure. The two
|
||||||
|
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
|
||||||
|
* as they stood on 22 Sept, so the expectations read like a Sunday would.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const {
|
||||||
|
MESSAGE_THRESHOLD,
|
||||||
|
leadingDateCode,
|
||||||
|
messageDocumentScore,
|
||||||
|
messageItemOf,
|
||||||
|
playlistMirrorsPlan,
|
||||||
|
placeDocument,
|
||||||
|
} = require('./matching');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed += 1;
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` FAIL ${name}\n ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
|
||||||
|
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
|
||||||
|
|
||||||
|
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
|
||||||
|
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
|
||||||
|
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
|
||||||
|
|
||||||
|
// North Attleboro — the message is an item called "Message", linked in
|
||||||
|
// ProPresenter to the bumper video; the sermon slides are their own document.
|
||||||
|
const NA = [
|
||||||
|
header('h0', 'Doors Open'),
|
||||||
|
item('1', 'Preservice'),
|
||||||
|
item('2', '5 Minute Countdown', 'media'),
|
||||||
|
item('3', 'MC Announcements', 'media'),
|
||||||
|
header('h4', 'Worship'),
|
||||||
|
item('5', 'Call To Worship', 'media'),
|
||||||
|
item('6', 'Alleluia', 'song', 'Alleluia'),
|
||||||
|
item('7', 'What a God', 'song', 'What a God'),
|
||||||
|
item('8', 'Baptisms', 'media'),
|
||||||
|
item('9', 'Promised Land', 'song', 'Promised Land'),
|
||||||
|
item('10', 'Be Praised', 'song', 'Be Praised'),
|
||||||
|
header('h11', 'Message'),
|
||||||
|
item('12', 'Message'),
|
||||||
|
item('13', 'Standing Salvation Moment', 'media'),
|
||||||
|
header('h14', 'Closing'),
|
||||||
|
item('15', 'Postservice', 'song', '-Postservice'),
|
||||||
|
];
|
||||||
|
const NA_PP = [
|
||||||
|
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
|
||||||
|
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
|
||||||
|
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
|
||||||
|
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
|
||||||
|
pp('Closing', 'header'), pp('Postservice'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apollo Beach — the message is the media item "Resi Message".
|
||||||
|
const AB = [
|
||||||
|
item('0', 'Pre Service Meeting', 'media'),
|
||||||
|
header('h1', 'Doors Open'),
|
||||||
|
item('2', 'Pre Service', 'media'),
|
||||||
|
item('3', '5 Minute Countdown', 'media'),
|
||||||
|
item('4', 'MC Announcements', 'media'),
|
||||||
|
header('h5', 'Worship'),
|
||||||
|
item('6', 'Call To Worship', 'media'),
|
||||||
|
item('7', 'Alleluia', 'song', 'Alleluia'),
|
||||||
|
item('8', 'What a God', 'song', 'What a God'),
|
||||||
|
item('9', 'The Blood', 'song', 'The Blood'),
|
||||||
|
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
|
||||||
|
header('h11', 'Message'),
|
||||||
|
item('12', 'Resi Message', 'media'),
|
||||||
|
item('13', 'Standing Salvation Moment', 'media'),
|
||||||
|
header('h14', 'Closing'),
|
||||||
|
item('15', 'Post Service', 'media'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERMON = '260927 Where is Your Faith? - Part 6';
|
||||||
|
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
|
||||||
|
const titleOf = (hit) => (hit ? hit.item.title : null);
|
||||||
|
|
||||||
|
console.log('date codes');
|
||||||
|
|
||||||
|
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
|
||||||
|
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
|
||||||
|
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
|
||||||
|
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
|
||||||
|
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
|
||||||
|
assert.strictEqual(leadingDateCode('261340 no such month'), null);
|
||||||
|
assert.strictEqual(leadingDateCode('Alleluia'), null);
|
||||||
|
assert.strictEqual(leadingDateCode(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('the message rule');
|
||||||
|
|
||||||
|
test('a document named like the plan, dated with its day, is the message', () => {
|
||||||
|
const hit = messageDocumentScore(SERMON, PLAN);
|
||||||
|
assert.ok(hit, 'recognised');
|
||||||
|
assert.strictEqual(hit.dated, true);
|
||||||
|
assert.strictEqual(hit.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("last week's document is not this plan's message", () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undated, series and part together still read as the message', () => {
|
||||||
|
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
|
||||||
|
assert.ok(plain && plain.dated === false && plain.score === 1);
|
||||||
|
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
|
||||||
|
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a series bumper is not the sermon', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
|
||||||
|
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dated with the plan's day, the part alone is enough", () => {
|
||||||
|
const hit = messageDocumentScore('260927 Part 6', PLAN);
|
||||||
|
assert.ok(hit && hit.dated);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dated document about something else is not the message', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs are not the message', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
|
||||||
|
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan with neither series nor title has no message document', () => {
|
||||||
|
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
|
||||||
|
assert.strictEqual(messageDocumentScore(SERMON, null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan without a date still recognises its own name', () => {
|
||||||
|
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
|
||||||
|
assert.ok(hit && hit.dated === false && hit.score === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('the message item');
|
||||||
|
|
||||||
|
test('an item called Message is the message item; so is Resi Message', () => {
|
||||||
|
assert.strictEqual(messageItemOf(NA).id, '12');
|
||||||
|
assert.strictEqual(messageItemOf(AB).id, '12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare "Message" beats an item that only contains the word', () => {
|
||||||
|
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
|
||||||
|
assert.strictEqual(messageItemOf(withBumper).id, '12');
|
||||||
|
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('under a Sermon header, the first item is the message whatever it is called', () => {
|
||||||
|
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
|
||||||
|
assert.strictEqual(messageItemOf(plan).id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan without a message slot has none', () => {
|
||||||
|
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
|
||||||
|
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
|
||||||
|
assert.strictEqual(messageItemOf([]), null);
|
||||||
|
assert.strictEqual(messageItemOf(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('the playlist mirror');
|
||||||
|
|
||||||
|
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
|
||||||
|
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
|
||||||
|
const shifted = [...NA_PP];
|
||||||
|
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
|
||||||
|
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
|
||||||
|
assert.strictEqual(playlistMirrorsPlan([], []), false);
|
||||||
|
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('placing what is live');
|
||||||
|
|
||||||
|
test('songs and media land by name, tags and all', () => {
|
||||||
|
const god = place('What a God - [ Full ]', NA);
|
||||||
|
assert.strictEqual(titleOf(god), 'What a God');
|
||||||
|
assert.strictEqual(god.via, 'name');
|
||||||
|
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
|
||||||
|
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
|
||||||
|
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sermon document lands on the message item at both campuses', () => {
|
||||||
|
const na = place(SERMON, NA);
|
||||||
|
assert.strictEqual(titleOf(na), 'Message');
|
||||||
|
assert.strictEqual(na.via, 'message document, dated');
|
||||||
|
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
|
||||||
|
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the bumper lands on the message item too, by name', () => {
|
||||||
|
const hit = place('Message Bumper', NA);
|
||||||
|
assert.strictEqual(titleOf(hit), 'Message');
|
||||||
|
assert.strictEqual(hit.via, 'name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a song called Faith does not catch the sermon', () => {
|
||||||
|
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
|
||||||
|
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
|
||||||
|
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
|
||||||
|
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item titled exactly like the document wins over the message slot', () => {
|
||||||
|
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
|
||||||
|
assert.strictEqual(place(SERMON, titled).item.id, 's');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan without a message slot leaves the sermon document unplaced', () => {
|
||||||
|
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
|
||||||
|
assert.strictEqual(place(SERMON, noSlot), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unplanned document, or last week's sermon, lands nowhere", () => {
|
||||||
|
assert.strictEqual(place('Offering Slide', NA), null);
|
||||||
|
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing to place on an empty plan', () => {
|
||||||
|
assert.strictEqual(place(SERMON, []), null);
|
||||||
|
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same song twice: a reprise after "What a God".
|
||||||
|
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
|
||||||
|
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
|
||||||
|
|
||||||
|
test('a tie between equal names goes to the one ahead of the current item', () => {
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position breaks a tie only while the playlist mirrors the plan', () => {
|
||||||
|
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
|
||||||
|
assert.strictEqual(mirror, true);
|
||||||
|
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
|
||||||
|
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position never places a document on its own', () => {
|
||||||
|
const mirror = playlistMirrorsPlan(NA_PP, NA);
|
||||||
|
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
|
||||||
|
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} tests passed`);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service from Planning Center Services",
|
"description": "Order of service from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
+69
-34
@@ -33,8 +33,12 @@
|
|||||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* this module reads the propresenter-now-next module's own SSE feed over
|
||||||
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
||||||
* matches the live presentation name to a plan item. Item start/end
|
* places the live document on a plan item (matching.js placeDocument): by
|
||||||
* timestamps are tracked here, once, so every tile agrees on the runtime.
|
* name, or — for the sermon, whose document is named after the sermon and
|
||||||
|
* never after its plan item — by reading like the plan itself ("YYMMDD
|
||||||
|
* Series Title - Part #"), which lands it on the plan's message item. Item
|
||||||
|
* start/end timestamps are tracked here, once, so every tile agrees on the
|
||||||
|
* runtime.
|
||||||
*
|
*
|
||||||
* Service timing: a plan's services (9:00, 11:00…) take turns owning the
|
* Service timing: a plan's services (9:00, 11:00…) take turns owning the
|
||||||
* service clock — the rules live in service-timing.js. A service starts at
|
* service clock — the rules live in service-timing.js. A service starts at
|
||||||
@@ -52,7 +56,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { MATCH_THRESHOLD, similarity } = require('./matching');
|
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||||
const {
|
const {
|
||||||
MINUTE,
|
MINUTE,
|
||||||
serviceTimes,
|
serviceTimes,
|
||||||
@@ -434,6 +438,8 @@ module.exports = {
|
|||||||
let inFlight = null;
|
let inFlight = null;
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
let lastTimersSig = '';
|
let lastTimersSig = '';
|
||||||
|
let lastMessageItemId = null;
|
||||||
|
let lastMatchError = '';
|
||||||
|
|
||||||
/* — broadcasting — */
|
/* — broadcasting — */
|
||||||
|
|
||||||
@@ -582,6 +588,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
state.plan = plan;
|
state.plan = plan;
|
||||||
state.items = items;
|
state.items = items;
|
||||||
|
noteMessageItem(items);
|
||||||
state.reachable = true;
|
state.reachable = true;
|
||||||
state.lastError = '';
|
state.lastError = '';
|
||||||
state.lastFetched = Date.now();
|
state.lastFetched = Date.now();
|
||||||
@@ -596,6 +603,17 @@ module.exports = {
|
|||||||
syncServiceSafe(Date.now(), null);
|
syncServiceSafe(Date.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Say, once per plan, which item a document named like the plan will land on. */
|
||||||
|
function noteMessageItem(items) {
|
||||||
|
const msg = messageItemOf(items);
|
||||||
|
const id = msg ? msg.id : '';
|
||||||
|
if (id === lastMessageItemId) return;
|
||||||
|
lastMessageItemId = id;
|
||||||
|
log(msg
|
||||||
|
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
|
||||||
|
: 'no message item in this plan — a document named like the plan has nowhere to land');
|
||||||
|
}
|
||||||
|
|
||||||
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
||||||
|
|
||||||
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
||||||
@@ -996,36 +1014,29 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
lastLiveName = keep.activeName || '';
|
lastLiveName = keep.activeName || '';
|
||||||
if (follow && keep.activeName) {
|
if (follow && keep.activeName) {
|
||||||
const hit = matchItem(keep.activeName);
|
const hit = matchItem(keep.activeName, hintFrom(lastView));
|
||||||
state.live.matched = Boolean(hit);
|
state.live.matched = Boolean(hit);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
state.live.currentScore = hit.score;
|
state.live.currentScore = hit.score;
|
||||||
startItem(hit.item.id);
|
startItem(hit.item.id, keep.activeName, hit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
|
/**
|
||||||
function matchItem(name) {
|
* The plan item a live document stands for — { item, score, via } or
|
||||||
const items = state.items.filter((it) => it.itemType !== 'header');
|
* null. The rules live in matching.js (placeDocument): by name, then the
|
||||||
if (!items.length) return null;
|
* message rule for a document named like the plan itself; the playlist
|
||||||
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
|
* position only breaks ties, and only while the playlist mirrors the plan.
|
||||||
let best = null;
|
*/
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
function matchItem(name, hint) {
|
||||||
const item = items[i];
|
return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
|
||||||
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
}
|
||||||
if (score < MATCH_THRESHOLD) continue;
|
|
||||||
// Distance ahead of the current item (wrapping so items behind rank last).
|
/** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
|
||||||
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
function hintFrom(view) {
|
||||||
if (
|
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
|
||||||
!best
|
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
|
||||||
|| score > best.score + 0.001
|
|
||||||
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
|
|
||||||
) {
|
|
||||||
best = { item, score, dist };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */
|
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */
|
||||||
@@ -1077,6 +1088,7 @@ module.exports = {
|
|||||||
function applyLiveView(view) {
|
function applyLiveView(view) {
|
||||||
lastView = view && typeof view === 'object' ? view : null;
|
lastView = view && typeof view === 'object' ? view : null;
|
||||||
const live = state.live;
|
const live = state.live;
|
||||||
|
if (!live.available) log('following the ProPresenter module feed');
|
||||||
live.available = true;
|
live.available = true;
|
||||||
live.reason = '';
|
live.reason = '';
|
||||||
live.ppEnabled = Boolean(view?.enabled);
|
live.ppEnabled = Boolean(view?.enabled);
|
||||||
@@ -1095,23 +1107,40 @@ module.exports = {
|
|||||||
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
|
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastLiveName = name;
|
|
||||||
rememberDocument(name);
|
rememberDocument(name);
|
||||||
if (follow) {
|
if (follow) {
|
||||||
const hit = matchItem(name);
|
try {
|
||||||
live.matched = Boolean(hit);
|
const hit = matchItem(name, hintFrom(view));
|
||||||
if (hit) {
|
live.matched = Boolean(hit);
|
||||||
live.currentScore = hit.score;
|
if (hit) {
|
||||||
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
live.currentScore = hit.score;
|
||||||
|
if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
|
||||||
|
} else {
|
||||||
|
// Said in the log, because on the wall it looks like the plan
|
||||||
|
// stopped following: the highlight stays where it was.
|
||||||
|
const staying = state.items.find((it) => it.id === live.currentItemId);
|
||||||
|
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
|
||||||
|
}
|
||||||
|
lastMatchError = '';
|
||||||
|
} catch (err) {
|
||||||
|
// Never silent: a matcher that throws would otherwise leave the
|
||||||
|
// highlight stuck with nothing in the log to say why.
|
||||||
|
const msg = err?.message || String(err);
|
||||||
|
if (msg !== lastMatchError) {
|
||||||
|
lastMatchError = msg;
|
||||||
|
log(`matching "${name}" failed: ${msg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastLiveName = name;
|
||||||
// The same document is also a possible service start / end cue.
|
// The same document is also a possible service start / end cue.
|
||||||
syncServiceSafe(Date.now(), name);
|
syncServiceSafe(Date.now(), name);
|
||||||
broadcast();
|
broadcast();
|
||||||
broadcastTimers();
|
broadcastTimers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startItem(itemId) {
|
/** Make an item current; `liveName` and `hit` say what placed it, for the log. */
|
||||||
|
function startItem(itemId, liveName, hit) {
|
||||||
const live = state.live;
|
const live = state.live;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||||||
@@ -1120,12 +1149,18 @@ module.exports = {
|
|||||||
live.currentItemId = itemId;
|
live.currentItemId = itemId;
|
||||||
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||||||
const item = state.items.find((it) => it.id === itemId);
|
const item = state.items.find((it) => it.id === itemId);
|
||||||
log(`live item → ${item?.title || itemId}`);
|
const how = hit
|
||||||
|
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
|
||||||
|
: '';
|
||||||
|
log(`live item → ${item?.title || itemId}${how}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowNextUnavailable(reason, retryMs) {
|
function nowNextUnavailable(reason, retryMs) {
|
||||||
const live = state.live;
|
const live = state.live;
|
||||||
if (live.available || live.reason !== reason) {
|
if (live.available || live.reason !== reason) {
|
||||||
|
// Every drop is in the log: a highlight that stopped moving is
|
||||||
|
// otherwise indistinguishable from a feed that went away.
|
||||||
|
log(`ProPresenter module feed unavailable — ${reason}`);
|
||||||
live.available = false;
|
live.available = false;
|
||||||
live.reason = reason;
|
live.reason = reason;
|
||||||
live.ppReachable = false;
|
live.ppReachable = false;
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/* Service Times — the board.
|
||||||
|
*
|
||||||
|
* One card per campus, one row per service, and a mark on any service that
|
||||||
|
* production has to treat differently this week. Everything is drawn from the
|
||||||
|
* server's SSE state so every booth sees the same marks at the same moment;
|
||||||
|
* nothing here talks to the website.
|
||||||
|
*
|
||||||
|
* Marking lives in the tile body, not the title bar, because the bar folds
|
||||||
|
* away in fullscreen and on an auto-hiding dashboard — a control an operator
|
||||||
|
* needs mid-service may not disappear with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ value: 'ok', label: 'Clear', short: '' },
|
||||||
|
{ value: 'issue', label: 'Issue', short: 'Issue' },
|
||||||
|
{ value: 'replay', label: 'Replay', short: 'Replay' },
|
||||||
|
{ value: 'skip', label: 'Do not use', short: 'Do not use' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||||
|
+ '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
|
||||||
|
+ '<path d="M13.6 1.9v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
const ICON_CLEAR = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||||
|
+ '<path d="M3 4h10M6.5 4V2.8h3V4M4.4 4l.7 8.4a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9L11.6 4"'
|
||||||
|
+ ' fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
const ICON_SMALLER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||||
|
+ '<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||||
|
const ICON_BIGGER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||||||
|
+ '<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||||
|
|
||||||
|
const TEXT_MIN = 0.8;
|
||||||
|
const TEXT_MAX = 1.8;
|
||||||
|
const TEXT_STEP = 0.1;
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let stream = null;
|
||||||
|
let state = null;
|
||||||
|
let editing = null; // { campus, service } while the editor is open
|
||||||
|
let pendingRender = false; // a state arrived while the editor was open
|
||||||
|
let board = null;
|
||||||
|
let closeEditor = null;
|
||||||
|
|
||||||
|
const settings = () => moduleApi.instanceSettings;
|
||||||
|
|
||||||
|
function el(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text != null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The campuses this tile is asked to show; no choice made means all of them. */
|
||||||
|
function visibleCampuses() {
|
||||||
|
const all = state?.campuses || [];
|
||||||
|
const chosen = settings().campuses;
|
||||||
|
if (!Array.isArray(chosen) || !chosen.length) return all;
|
||||||
|
return all.filter((campus) => chosen.includes(campus.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
function textScale() {
|
||||||
|
const saved = Number(settings().textSize);
|
||||||
|
return Number.isFinite(saved) ? Math.min(TEXT_MAX, Math.max(TEXT_MIN, saved)) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceLabel(service) {
|
||||||
|
return service.label ? `${service.label} ${service.text}` : service.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every service of the campus a resi campus takes its feed from. */
|
||||||
|
function sourceServices(campus) {
|
||||||
|
if (!campus.source) return [];
|
||||||
|
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||||
|
return origin ? origin.services : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mark on the origin service a resi service is taking, if any. */
|
||||||
|
function upstreamTrouble(campus, service) {
|
||||||
|
const sourceId = service.mark?.source;
|
||||||
|
if (!sourceId || !campus.source) return null;
|
||||||
|
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||||
|
const upstream = origin?.services.find((s) => s.id === sourceId);
|
||||||
|
if (!upstream?.mark || upstream.mark.status === 'ok') return null;
|
||||||
|
return { origin, upstream };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resi services pointed at this broadcast service. */
|
||||||
|
function dependentsOf(campus, service) {
|
||||||
|
if (campus.role !== 'broadcast') return [];
|
||||||
|
const out = [];
|
||||||
|
for (const other of state?.campuses || []) {
|
||||||
|
if (other.source !== campus.slug) continue;
|
||||||
|
for (const theirs of other.services) {
|
||||||
|
if (theirs.mark?.source === service.id) out.push({ campus: other, service: theirs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── drawing ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function renderServiceRow(campus, service) {
|
||||||
|
const opts = settings();
|
||||||
|
const row = el('button', 'st-row');
|
||||||
|
row.type = 'button';
|
||||||
|
const status = service.mark?.status || 'ok';
|
||||||
|
if (status !== 'ok') row.classList.add(`st-${status}`);
|
||||||
|
|
||||||
|
const time = el('span', 'st-time', serviceLabel(service));
|
||||||
|
row.appendChild(time);
|
||||||
|
|
||||||
|
if (opts.showDay && service.day) row.appendChild(el('span', 'st-day', service.day));
|
||||||
|
|
||||||
|
const chips = el('span', 'st-chips');
|
||||||
|
const statusDef = STATUSES.find((s) => s.value === status);
|
||||||
|
if (statusDef?.short) chips.appendChild(el('span', `st-chip st-chip-${status}`, statusDef.short));
|
||||||
|
|
||||||
|
// A resi service says which of the origin's services it is taking…
|
||||||
|
if (opts.showSource && campus.role === 'resi' && service.mark?.source) {
|
||||||
|
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
|
||||||
|
const upstream = origin?.services.find((s) => s.id === service.mark.source);
|
||||||
|
if (upstream) chips.appendChild(el('span', 'st-chip st-chip-source', `← ${upstream.text}`));
|
||||||
|
}
|
||||||
|
// …and turns red when that service is the one in trouble.
|
||||||
|
const trouble = upstreamTrouble(campus, service);
|
||||||
|
if (trouble) {
|
||||||
|
row.classList.add('st-affected');
|
||||||
|
const what = STATUSES.find((s) => s.value === trouble.upstream.mark.status)?.short || 'marked';
|
||||||
|
chips.appendChild(el('span', 'st-chip st-chip-skip', `source ${what.toLowerCase()}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broadcast service says who is riding on it.
|
||||||
|
if (opts.showDependents && campus.role === 'broadcast') {
|
||||||
|
const riders = dependentsOf(campus, service);
|
||||||
|
if (riders.length && status !== 'ok') {
|
||||||
|
chips.appendChild(el('span', 'st-chip st-chip-warn',
|
||||||
|
`affects ${riders.map((r) => r.campus.name).join(', ')}`));
|
||||||
|
} else if (riders.length) {
|
||||||
|
chips.appendChild(el('span', 'st-chip st-chip-quiet', `→ ${riders.length}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.appendChild(chips);
|
||||||
|
|
||||||
|
if (opts.showNotes && service.mark?.note) {
|
||||||
|
row.appendChild(el('span', 'st-note', service.mark.note));
|
||||||
|
}
|
||||||
|
|
||||||
|
row.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openEditor(campus, service, row);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCampus(campus) {
|
||||||
|
const opts = settings();
|
||||||
|
const card = el('section', 'st-campus');
|
||||||
|
if (campus.role === 'broadcast') card.classList.add('st-is-broadcast');
|
||||||
|
|
||||||
|
const head = el('header', 'st-campus-head');
|
||||||
|
head.appendChild(el('h3', 'st-campus-name', campus.name));
|
||||||
|
|
||||||
|
if (opts.showRole) {
|
||||||
|
if (campus.role === 'broadcast') {
|
||||||
|
head.appendChild(el('span', 'st-role st-role-broadcast', 'Broadcast'));
|
||||||
|
} else if (campus.source) {
|
||||||
|
head.appendChild(el('span', 'st-role', campus.sourceMissing
|
||||||
|
? `resi ← ${campus.sourceName} (not listed)`
|
||||||
|
: `resi ← ${campus.sourceName}`));
|
||||||
|
} else {
|
||||||
|
head.appendChild(el('span', 'st-role st-role-unset', 'no resi source'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (campus.manual) head.appendChild(el('span', 'st-role st-role-manual', 'manual'));
|
||||||
|
card.appendChild(head);
|
||||||
|
|
||||||
|
if (opts.showDependents && campus.role === 'broadcast' && campus.dependents?.length) {
|
||||||
|
card.appendChild(el('p', 'st-feeds', `feeds ${campus.dependents.map((d) => d.name).join(', ')}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campus.services.length) {
|
||||||
|
card.appendChild(el('p', 'st-empty', campus.error || 'No times published'));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
const list = el('div', 'st-rows');
|
||||||
|
for (const service of campus.services) list.appendChild(renderServiceRow(campus, service));
|
||||||
|
card.appendChild(list);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (closeEditor) closeEditor();
|
||||||
|
root.innerHTML = '';
|
||||||
|
board = el('div', 'st-board');
|
||||||
|
board.style.fontSize = `${textScale()}em`;
|
||||||
|
root.appendChild(board);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
board.appendChild(el('p', 'st-empty', 'Waiting for the server…'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.configured) {
|
||||||
|
board.appendChild(el('p', 'st-empty', 'No website address configured — set one in Admin.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const campuses = visibleCampuses();
|
||||||
|
if (!campuses.length) {
|
||||||
|
board.appendChild(el('p', 'st-empty', state.reachable
|
||||||
|
? 'No campuses to show. Pick some in Admin, or in this tile’s settings.'
|
||||||
|
: 'No service times yet.'));
|
||||||
|
} else {
|
||||||
|
const grid = el('div', settings().layout === 'list' ? 'st-grid st-grid-list' : 'st-grid');
|
||||||
|
for (const campus of campuses) grid.appendChild(renderCampus(campus));
|
||||||
|
board.appendChild(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.reachable && state.lastError) {
|
||||||
|
board.appendChild(el('p', 'st-warn', `Website unreachable — ${state.lastError}. Showing the last times read.`));
|
||||||
|
}
|
||||||
|
if (settings().showUpdated && state.lastFetched) {
|
||||||
|
board.appendChild(el('p', 'st-updated',
|
||||||
|
`Times read ${new Date(state.lastFetched).toLocaleString([], { hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the mark editor ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function openEditor(campus, service, anchor) {
|
||||||
|
if (closeEditor) closeEditor();
|
||||||
|
editing = { campus: campus.slug, service: service.id };
|
||||||
|
|
||||||
|
const panel = el('div', 'st-editor');
|
||||||
|
panel.appendChild(el('div', 'st-editor-title', `${campus.name} — ${serviceLabel(service)}`));
|
||||||
|
|
||||||
|
const mark = service.mark || { status: 'ok', note: '', source: '' };
|
||||||
|
let status = mark.status || 'ok';
|
||||||
|
|
||||||
|
const row = el('div', 'st-editor-status');
|
||||||
|
const buttons = new Map();
|
||||||
|
for (const def of STATUSES) {
|
||||||
|
const button = el('button', 'st-pick', def.label);
|
||||||
|
button.type = 'button';
|
||||||
|
if (def.value === status) button.classList.add('active');
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
status = def.value;
|
||||||
|
for (const [value, node] of buttons) node.classList.toggle('active', value === status);
|
||||||
|
});
|
||||||
|
buttons.set(def.value, button);
|
||||||
|
row.appendChild(button);
|
||||||
|
}
|
||||||
|
panel.appendChild(row);
|
||||||
|
|
||||||
|
// Which of the origin's services this one is taking — the answer to
|
||||||
|
// "which service, and where, can be altered".
|
||||||
|
let sourceSelect = null;
|
||||||
|
const origins = campus.role === 'resi' ? sourceServices(campus) : [];
|
||||||
|
if (origins.length) {
|
||||||
|
const wrap = el('label', 'st-editor-field');
|
||||||
|
wrap.appendChild(el('span', 'st-editor-label', `Taking from ${campus.sourceName}`));
|
||||||
|
sourceSelect = el('select', 'st-editor-input');
|
||||||
|
const none = el('option', null, '— not set —');
|
||||||
|
none.value = '';
|
||||||
|
sourceSelect.appendChild(none);
|
||||||
|
for (const candidate of origins) {
|
||||||
|
const option = el('option', null, serviceLabel(candidate));
|
||||||
|
option.value = candidate.id;
|
||||||
|
if (candidate.id === mark.source) option.selected = true;
|
||||||
|
sourceSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
wrap.appendChild(sourceSelect);
|
||||||
|
panel.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteWrap = el('label', 'st-editor-field');
|
||||||
|
noteWrap.appendChild(el('span', 'st-editor-label', 'Note'));
|
||||||
|
const note = el('input', 'st-editor-input');
|
||||||
|
note.type = 'text';
|
||||||
|
note.maxLength = 280;
|
||||||
|
note.value = mark.note || '';
|
||||||
|
note.placeholder = 'What production needs to know';
|
||||||
|
noteWrap.appendChild(note);
|
||||||
|
panel.appendChild(noteWrap);
|
||||||
|
|
||||||
|
const actions = el('div', 'st-editor-actions');
|
||||||
|
const save = el('button', 'st-pick st-save', 'Save');
|
||||||
|
save.type = 'button';
|
||||||
|
const cancel = el('button', 'st-pick', 'Cancel');
|
||||||
|
cancel.type = 'button';
|
||||||
|
actions.append(save, cancel);
|
||||||
|
panel.appendChild(actions);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const body = {
|
||||||
|
campus: campus.slug,
|
||||||
|
service: service.id,
|
||||||
|
status,
|
||||||
|
note: note.value.trim(),
|
||||||
|
source: sourceSelect ? sourceSelect.value : (mark.source || ''),
|
||||||
|
};
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
const response = await moduleApi.fetch('/mark', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`${response.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
moduleApi.setStatus('error', `Could not save the mark: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save.addEventListener('click', commit);
|
||||||
|
cancel.addEventListener('click', () => close());
|
||||||
|
note.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') commit();
|
||||||
|
if (event.key === 'Escape') close();
|
||||||
|
});
|
||||||
|
panel.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
|
||||||
|
function onOutside() { close(); }
|
||||||
|
function onKey(event) { if (event.key === 'Escape') close(); }
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (closeEditor !== close) return;
|
||||||
|
panel.remove();
|
||||||
|
anchor?.classList.remove('st-open');
|
||||||
|
root.removeEventListener('click', onOutside);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
closeEditor = null;
|
||||||
|
editing = null;
|
||||||
|
if (pendingRender) {
|
||||||
|
pendingRender = false;
|
||||||
|
render(); // closeEditor is already null, so this cannot re-enter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditor = close;
|
||||||
|
anchor?.classList.add('st-open');
|
||||||
|
board.appendChild(panel);
|
||||||
|
root.addEventListener('click', onOutside);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
note.focus();
|
||||||
|
note.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── lifecycle ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function applyState(next) {
|
||||||
|
state = next;
|
||||||
|
if (!state) {
|
||||||
|
moduleApi.setStatus('connecting', 'Waiting for the server');
|
||||||
|
} else if (!state.configured) {
|
||||||
|
moduleApi.setStatus('error', 'No website address configured');
|
||||||
|
} else if (!state.reachable) {
|
||||||
|
moduleApi.setStatus('error', state.lastError
|
||||||
|
? `Website unreachable — ${state.lastError}`
|
||||||
|
: 'Website unreachable');
|
||||||
|
} else if (state.lastError) {
|
||||||
|
moduleApi.setStatus('connecting', state.lastError);
|
||||||
|
} else {
|
||||||
|
const count = (state.campuses || []).reduce((sum, c) => sum + c.services.length, 0);
|
||||||
|
moduleApi.setStatus('ok', `${count} service(s) across ${(state.campuses || []).length} campus(es)`);
|
||||||
|
}
|
||||||
|
// Re-rendering would close an open editor mid-sentence, so hold the change
|
||||||
|
// back while someone is typing — and draw it the moment they are done, or
|
||||||
|
// the board would sit stale until the next change happened to arrive.
|
||||||
|
if (editing) {
|
||||||
|
pendingRender = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
try {
|
||||||
|
const response = await moduleApi.fetch('/state');
|
||||||
|
const body = await response.json();
|
||||||
|
applyState(body.state);
|
||||||
|
} catch {
|
||||||
|
/* the stream will bring it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextSize(delta) {
|
||||||
|
const next = Math.min(TEXT_MAX, Math.max(TEXT_MIN, textScale() + delta));
|
||||||
|
moduleApi.saveInstanceSettings({ textSize: Number(next.toFixed(2)) });
|
||||||
|
if (board) board.style.fontSize = `${next}em`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
moduleApi.setStatus('connecting', 'Reading service times');
|
||||||
|
render();
|
||||||
|
|
||||||
|
moduleApi.header.addButton({
|
||||||
|
icon: ICON_REFRESH,
|
||||||
|
title: 'Refresh times',
|
||||||
|
onClick: async () => {
|
||||||
|
moduleApi.setStatus('connecting', 'Re-reading the website');
|
||||||
|
try {
|
||||||
|
const response = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.state) applyState(body.state);
|
||||||
|
} catch (err) {
|
||||||
|
moduleApi.setStatus('error', `Refresh failed: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
moduleApi.header.addButton({
|
||||||
|
icon: ICON_CLEAR,
|
||||||
|
title: 'Clear marks',
|
||||||
|
onClick: async () => {
|
||||||
|
const marked = (state?.campuses || []).reduce(
|
||||||
|
(sum, campus) => sum + campus.services.filter((s) => s.mark).length, 0
|
||||||
|
);
|
||||||
|
if (!marked) return;
|
||||||
|
// Marks are shared by every campus watching — never wipe them silently.
|
||||||
|
if (!window.confirm(`Clear ${marked} mark${marked === 1 ? '' : 's'} for every campus?`)) return;
|
||||||
|
try {
|
||||||
|
await moduleApi.fetch('/clear', { method: 'POST' });
|
||||||
|
} catch (err) {
|
||||||
|
moduleApi.setStatus('error', `Could not clear marks: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
moduleApi.header.addButton({
|
||||||
|
icon: ICON_SMALLER,
|
||||||
|
title: 'Smaller text',
|
||||||
|
onClick: () => setTextSize(-TEXT_STEP),
|
||||||
|
});
|
||||||
|
moduleApi.header.addButton({
|
||||||
|
icon: ICON_BIGGER,
|
||||||
|
title: 'Larger text',
|
||||||
|
onClick: () => setTextSize(TEXT_STEP),
|
||||||
|
});
|
||||||
|
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
open() {
|
||||||
|
// The shared connection was (re)made — we may have missed a change.
|
||||||
|
loadState();
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
state(event) {
|
||||||
|
try {
|
||||||
|
applyState(JSON.parse(event.data));
|
||||||
|
} catch { /* a truncated frame; the next one will be whole */ }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loadState();
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
pendingRender = false; // nothing to catch up on: the tile is going away
|
||||||
|
if (closeEditor) closeEditor();
|
||||||
|
stream?.close();
|
||||||
|
stream = null;
|
||||||
|
state = null;
|
||||||
|
editing = null;
|
||||||
|
board = null;
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"id": "service-times",
|
||||||
|
"name": "Service Times",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.4.0",
|
||||||
|
"description": "Sunday service times for every campus, marked up for replays and services production cannot use",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": { "w": 3, "h": 3 },
|
||||||
|
"defaultSize": { "w": 6, "h": 5 },
|
||||||
|
"configGroups": {
|
||||||
|
"Website": {
|
||||||
|
"help": "Service times are read from the campus pages at <site>/locations/<campus>/ and refreshed on their own, so the board follows the website whenever the times change. Campuses are discovered from the site's sitemap — tick the ones production is responsible for."
|
||||||
|
},
|
||||||
|
"Broadcast": {
|
||||||
|
"help": "Which campuses originate the Sunday broadcast, and which campus each of the others takes its resi from. This is what lets the board show, when a service is marked unusable, every campus affected by it."
|
||||||
|
},
|
||||||
|
"Manual campuses": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "A stopgap for a campus the website has not published yet. The website stays the source of truth: as soon as it carries a campus by this name, its times take over and the health line above says the line here can be removed."
|
||||||
|
},
|
||||||
|
"Marks": {
|
||||||
|
"collapsed": true,
|
||||||
|
"help": "Marks are shared by every tile and every browser, and survive a restart of ProdDash."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"siteUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Website",
|
||||||
|
"default": "https://waterschurch.org",
|
||||||
|
"group": "Website",
|
||||||
|
"help": "The church website's address. Campus pages are read from /locations/ underneath it."
|
||||||
|
},
|
||||||
|
"campuses": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Campuses",
|
||||||
|
"default": ["north-attleboro", "woonsocket", "southcoast", "apollo-beach"],
|
||||||
|
"group": "Website",
|
||||||
|
"optionsRoute": "/discovered",
|
||||||
|
"help": "Leave every campus unticked to follow all of them."
|
||||||
|
},
|
||||||
|
"refreshMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Refresh (min)",
|
||||||
|
"default": 360,
|
||||||
|
"group": "Website",
|
||||||
|
"help": "How often the website is re-read. Times change a few times a year, so this can be slow; a failed read retries every minute and the last good times stay on screen."
|
||||||
|
},
|
||||||
|
"broadcast": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Broadcast campuses",
|
||||||
|
"default": ["north-attleboro", "apollo-beach"],
|
||||||
|
"group": "Broadcast",
|
||||||
|
"optionsRoute": "/campuses",
|
||||||
|
"help": "The campuses the broadcast originates from."
|
||||||
|
},
|
||||||
|
"feeds": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Resi sources",
|
||||||
|
"default": "woonsocket = north-attleboro\nsouthcoast = north-attleboro",
|
||||||
|
"group": "Broadcast",
|
||||||
|
"help": "One campus per line: campus = the broadcast campus it takes resi from. Names or slugs both work."
|
||||||
|
},
|
||||||
|
"manual": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Campuses and times",
|
||||||
|
"default": "",
|
||||||
|
"group": "Manual campuses",
|
||||||
|
"help": "One campus per line: Name | Day | 9:00a, 11:00a. The day is optional and defaults to Sunday. Lines starting with # are ignored."
|
||||||
|
},
|
||||||
|
"retentionHours": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Marks clear after (h)",
|
||||||
|
"default": 36,
|
||||||
|
"group": "Marks",
|
||||||
|
"help": "A mark disappears this long after it was made, so Saturday's and Sunday's notes are gone before the next week. Set 0 to keep marks until they are cleared by hand."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Show": { "collapsed": true, "columns": 2 }
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"campuses": {
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Campuses",
|
||||||
|
"optionsRoute": "/campuses",
|
||||||
|
"columns": 2,
|
||||||
|
"allByDefault": true
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Layout",
|
||||||
|
"default": "cards",
|
||||||
|
"options": [
|
||||||
|
{ "value": "cards", "label": "Cards" },
|
||||||
|
{ "value": "list", "label": "List" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"showDay": { "type": "boolean", "label": "Day", "default": true, "group": "Show" },
|
||||||
|
"showRole": { "type": "boolean", "label": "Role", "default": true, "group": "Show" },
|
||||||
|
"showSource": { "type": "boolean", "label": "Resi source", "default": true, "group": "Show" },
|
||||||
|
"showDependents": { "type": "boolean", "label": "Campuses fed", "default": true, "group": "Show" },
|
||||||
|
"showNotes": { "type": "boolean", "label": "Notes", "default": true, "group": "Show" },
|
||||||
|
"showUpdated": { "type": "boolean", "label": "Updated line", "default": false, "group": "Show" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service Times — reading the church website.
|
||||||
|
*
|
||||||
|
* Pure functions, no I/O, so every shape below can be checked without the
|
||||||
|
* network (parse.test.js). The server entry does the fetching.
|
||||||
|
*
|
||||||
|
* Every campus page is built from the same page-builder blocks: a "Times"
|
||||||
|
* heading, the day in bold, then the service times under it, ending before
|
||||||
|
* the "Get Involved" heading. Bounding the slice that way is what keeps the
|
||||||
|
* footer's main-office hours out of the service list — and some campuses
|
||||||
|
* (Apollo Beach) carry their own "Office Hours" block inside the Times
|
||||||
|
* section, which is why STOP_HEADINGS ends the walk early as well.
|
||||||
|
*
|
||||||
|
* The four shapes seen in the wild, all handled by walkSection():
|
||||||
|
*
|
||||||
|
* Woonsocket / Apollo Beach Sunday → 9:00a, 11:00a
|
||||||
|
* 9:00a & 11:00a
|
||||||
|
* North Attleboro Sunday → 7:30a, 9:30a, 11:30a
|
||||||
|
* 7:30a<br>9:30a<br>11:30a
|
||||||
|
* Orlando Sundays → Welcome 10:00a,
|
||||||
|
* Welcome 10:00am Start 10:30a
|
||||||
|
* Start 10:30am
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEEKDAYS = [
|
||||||
|
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Headings that end the service list wherever they turn up in the section. */
|
||||||
|
const STOP_HEADINGS = /^(office\s*hours?|get\s*involved|location\s*pastor|contact|address|events?)\b/i;
|
||||||
|
|
||||||
|
/** A time: "9:00a", "11:30am", "7a", "10:00 a.m." — never "1 person". */
|
||||||
|
const TIME_RE = /(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*(?:m\.?)?(?![a-z])/gi;
|
||||||
|
|
||||||
|
/** "9:00a-4:30p" — an hours range, not two services. */
|
||||||
|
const RANGE_RE = /\d\s*(?::\d{2})?\s*[ap]\.?m?\.?\s*[-–—]\s*\d/i;
|
||||||
|
|
||||||
|
const NAMED_ENTITIES = {
|
||||||
|
amp: '&', nbsp: ' ', quot: '"', apos: "'", lt: '<', gt: '>',
|
||||||
|
ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
|
||||||
|
rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fromCodePoint(n) {
|
||||||
|
try {
|
||||||
|
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(input) {
|
||||||
|
return String(input == null ? '' : input)
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => fromCodePoint(parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => fromCodePoint(parseInt(dec, 10)))
|
||||||
|
.replace(/&([a-z]+);/gi, (whole, name) => {
|
||||||
|
const value = NAMED_ENTITIES[name.toLowerCase()];
|
||||||
|
return value === undefined ? whole : value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML → the lines a reader would see. Block ends and <br> become line
|
||||||
|
* breaks so "7:30a<br>9:30a" stays two services rather than one run-on.
|
||||||
|
*/
|
||||||
|
function htmlToLines(html) {
|
||||||
|
return String(html == null ? '' : html)
|
||||||
|
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/(p|div|li|ul|ol|h[1-6]|td|tr|span|strong|b|em|i|a)\s*>/gi, '\n')
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => decodeEntities(line).replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title Case a slug: "north-attleboro" → "North Attleboro". */
|
||||||
|
function nameFromSlug(slug) {
|
||||||
|
return String(slug || '')
|
||||||
|
.split(/[-_/]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name == null ? '' : name)
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A weekday heading — "Sunday", "Sundays", "SUNDAY" — and nothing else. */
|
||||||
|
function dayFromLine(line) {
|
||||||
|
const cleaned = String(line || '').toLowerCase().replace(/[^a-z]+/g, '');
|
||||||
|
if (!cleaned) return '';
|
||||||
|
for (const day of WEEKDAYS) {
|
||||||
|
if (cleaned === day || cleaned === `${day}s`) {
|
||||||
|
return day.charAt(0).toUpperCase() + day.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "9:00a" → { hour24: 9, minute: 0, text: "9:00a" }; 12a/12p handled. */
|
||||||
|
function toService(hourRaw, minuteRaw, meridiemRaw) {
|
||||||
|
const hour12 = Number(hourRaw);
|
||||||
|
const minute = Number(minuteRaw || 0);
|
||||||
|
const pm = String(meridiemRaw || '').toLowerCase() === 'p';
|
||||||
|
if (!Number.isFinite(hour12) || hour12 < 1 || hour12 > 12) return null;
|
||||||
|
if (!Number.isFinite(minute) || minute > 59) return null;
|
||||||
|
const hour24 = pm ? (hour12 === 12 ? 12 : hour12 + 12) : hour12 === 12 ? 0 : hour12;
|
||||||
|
return {
|
||||||
|
hour24,
|
||||||
|
minute,
|
||||||
|
minutes: hour24 * 60 + minute,
|
||||||
|
text: `${hour12}:${pad2(minute)}${pm ? 'p' : 'a'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Times on one line, with the words in front of the first one as a label
|
||||||
|
* ("Welcome 10:00am" → label "Welcome"). A line holding an hours range
|
||||||
|
* ("9:00a-4:30p") is office hours, not services, and yields nothing.
|
||||||
|
*/
|
||||||
|
function timesFromLine(line) {
|
||||||
|
const text = String(line || '');
|
||||||
|
if (RANGE_RE.test(text)) return [];
|
||||||
|
TIME_RE.lastIndex = 0;
|
||||||
|
const found = [];
|
||||||
|
let first = -1;
|
||||||
|
let match = TIME_RE.exec(text);
|
||||||
|
while (match) {
|
||||||
|
const service = toService(match[1], match[2], match[3]);
|
||||||
|
if (service) {
|
||||||
|
if (first < 0) first = match.index;
|
||||||
|
found.push(service);
|
||||||
|
}
|
||||||
|
match = TIME_RE.exec(text);
|
||||||
|
}
|
||||||
|
if (!found.length) return [];
|
||||||
|
const label = first > 0
|
||||||
|
? text.slice(0, first).replace(/[\s|,;:&/–—-]+$/, '').trim()
|
||||||
|
: '';
|
||||||
|
// A label belongs to a single time ("Start 10:30am"); a list of times on
|
||||||
|
// one line ("9:00a & 11:00a") shares none.
|
||||||
|
return found.map((service) => ({ ...service, label: found.length === 1 ? label : '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the lines of a Times section into services, stopping at the first
|
||||||
|
* stop-heading. Lines before any weekday heading still count — a campus
|
||||||
|
* that lists bare times under "Times" is read as Sunday.
|
||||||
|
*/
|
||||||
|
function walkSection(lines, { defaultDay = 'Sunday' } = {}) {
|
||||||
|
const services = [];
|
||||||
|
let day = '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (STOP_HEADINGS.test(line)) break;
|
||||||
|
const heading = dayFromLine(line);
|
||||||
|
if (heading) {
|
||||||
|
day = heading;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const service of timesFromLine(line)) {
|
||||||
|
services.push({ ...service, day: day || defaultDay });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give every service a stable id — marks are keyed on it, so it must not drift. */
|
||||||
|
function withIds(services) {
|
||||||
|
const seen = new Map();
|
||||||
|
return services.map((service) => {
|
||||||
|
const base = [
|
||||||
|
pad2(service.hour24) + pad2(service.minute),
|
||||||
|
slugify(service.label),
|
||||||
|
].filter(Boolean).join('-');
|
||||||
|
const count = (seen.get(base) || 0) + 1;
|
||||||
|
seen.set(base, count);
|
||||||
|
return { ...service, id: count === 1 ? base : `${base}-${count}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop exact duplicates (same day + time + label) while keeping order. */
|
||||||
|
function dedupe(services) {
|
||||||
|
const seen = new Set();
|
||||||
|
return services.filter((service) => {
|
||||||
|
const key = `${service.day}|${service.minutes}|${service.label.toLowerCase()}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of a campus page between the "Times" heading and whatever ends
|
||||||
|
* it. Returns '' when the page has no Times heading, which the caller
|
||||||
|
* reports rather than guessing at.
|
||||||
|
*/
|
||||||
|
function timesSection(html) {
|
||||||
|
const text = String(html == null ? '' : html);
|
||||||
|
const start = /<([a-z0-9]+)\b[^>]*>\s*times\s*<\/\1\s*>/i.exec(text)
|
||||||
|
|| />\s*times\s*</i.exec(text);
|
||||||
|
if (!start) return '';
|
||||||
|
const from = start.index + start[0].length;
|
||||||
|
const rest = text.slice(from);
|
||||||
|
const end = /<([a-z0-9]+)\b[^>]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest)
|
||||||
|
|| />\s*get\s+involved\s*</i.exec(rest);
|
||||||
|
return end ? rest.slice(0, end.index) : rest.slice(0, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One campus page → { slug, name, day, services, error }. `error` is set
|
||||||
|
* (and services empty) when the page no longer looks the way this parser
|
||||||
|
* expects, so the admin health line can say the site changed rather than
|
||||||
|
* quietly showing a campus with no services.
|
||||||
|
*/
|
||||||
|
function parseCampusPage(html, { slug = '', name = '' } = {}) {
|
||||||
|
const campusSlug = slugify(slug);
|
||||||
|
const campusName = name || nameFromSlug(campusSlug);
|
||||||
|
const section = timesSection(html);
|
||||||
|
if (!section) {
|
||||||
|
return { slug: campusSlug, name: campusName, services: [], error: 'No "Times" section on the page' };
|
||||||
|
}
|
||||||
|
const services = withIds(dedupe(walkSection(htmlToLines(section))));
|
||||||
|
if (!services.length) {
|
||||||
|
return { slug: campusSlug, name: campusName, services: [], error: 'No service times under "Times"' };
|
||||||
|
}
|
||||||
|
return { slug: campusSlug, name: campusName, services, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campus page slugs from a sitemap and/or any page's links. The sitemap is
|
||||||
|
* the fuller list — it carries campuses the navigation does not link yet.
|
||||||
|
*/
|
||||||
|
function discoverCampusSlugs(...sources) {
|
||||||
|
const slugs = new Set();
|
||||||
|
for (const source of sources) {
|
||||||
|
const text = String(source == null ? '' : source);
|
||||||
|
const re = /\/locations\/([a-z0-9][a-z0-9-]*)\/?/gi;
|
||||||
|
let match = re.exec(text);
|
||||||
|
while (match) {
|
||||||
|
const slug = slugify(match[1]);
|
||||||
|
if (slug && slug !== 'locations') slugs.add(slug);
|
||||||
|
match = re.exec(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...slugs].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin "Manual campuses" text: one campus per line,
|
||||||
|
*
|
||||||
|
* Name | Day | 9:00a, 11:00a
|
||||||
|
*
|
||||||
|
* Day is optional and defaults to Sunday: "Plymouth | 9:00a & 11:00a".
|
||||||
|
* A line the parser cannot read is returned in `errors` so the admin page
|
||||||
|
* can say which line, rather than the campus silently not appearing.
|
||||||
|
*/
|
||||||
|
function parseManualCampuses(text) {
|
||||||
|
const campuses = [];
|
||||||
|
const errors = [];
|
||||||
|
const lines = String(text == null ? '' : text).split(/\r?\n/);
|
||||||
|
lines.forEach((raw, index) => {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#')) return;
|
||||||
|
const parts = line.split('|').map((part) => part.trim());
|
||||||
|
const name = parts.shift() || '';
|
||||||
|
if (!name) return;
|
||||||
|
if (!parts.length) {
|
||||||
|
errors.push(`Line ${index + 1}: "${line}" has no times`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let day = '';
|
||||||
|
if (parts.length > 1) {
|
||||||
|
day = dayFromLine(parts[0]) || '';
|
||||||
|
if (day) parts.shift();
|
||||||
|
}
|
||||||
|
const services = withIds(dedupe(
|
||||||
|
walkSection(parts.join('\n').split('\n'), { defaultDay: day || 'Sunday' })
|
||||||
|
));
|
||||||
|
if (!services.length) {
|
||||||
|
errors.push(`Line ${index + 1}: no times found in "${line}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
campuses.push({ slug: slugify(name), name, services, manual: true, error: '' });
|
||||||
|
});
|
||||||
|
return { campuses, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Website campuses and manual ones into the one list the tiles draw.
|
||||||
|
*
|
||||||
|
* The website wins: a manual campus is a stopgap for a site that has not
|
||||||
|
* caught up, so as soon as the site publishes that campus (same slug) its
|
||||||
|
* times take over and the manual entry is marked `supersededBy` — the admin
|
||||||
|
* page says so, and the operator can delete the line.
|
||||||
|
*/
|
||||||
|
function mergeCampuses(siteCampuses, manualCampuses) {
|
||||||
|
const merged = [];
|
||||||
|
const bySlug = new Map();
|
||||||
|
for (const campus of siteCampuses || []) {
|
||||||
|
if (!campus || !campus.slug) continue;
|
||||||
|
const entry = { ...campus, manual: false, superseded: false };
|
||||||
|
bySlug.set(campus.slug, entry);
|
||||||
|
merged.push(entry);
|
||||||
|
}
|
||||||
|
for (const campus of manualCampuses || []) {
|
||||||
|
if (!campus || !campus.slug) continue;
|
||||||
|
const fromSite = bySlug.get(campus.slug);
|
||||||
|
if (fromSite) {
|
||||||
|
// The site has it now — its times are the ones that matter.
|
||||||
|
if (fromSite.services.length) {
|
||||||
|
fromSite.supersededManual = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The page exists but gave us nothing usable; the manual line still helps.
|
||||||
|
fromSite.services = campus.services;
|
||||||
|
fromSite.manual = true;
|
||||||
|
fromSite.error = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = { ...campus, superseded: false };
|
||||||
|
bySlug.set(campus.slug, entry);
|
||||||
|
merged.push(entry);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin "Resi sources" text: one campus per line,
|
||||||
|
*
|
||||||
|
* woonsocket = north-attleboro
|
||||||
|
*
|
||||||
|
* Names work as well as slugs — both sides are slugified, so an operator can
|
||||||
|
* write what they say out loud. "->", ":" and "→" read as "=".
|
||||||
|
*/
|
||||||
|
function parseFeeds(text) {
|
||||||
|
const feeds = new Map();
|
||||||
|
const errors = [];
|
||||||
|
String(text == null ? '' : text).split(/\r?\n/).forEach((raw, index) => {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith('#')) return;
|
||||||
|
const match = /^(.+?)\s*(?:=|->|:|→)\s*(.+)$/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
errors.push(`Line ${index + 1}: "${line}" is not "campus = broadcast campus"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = slugify(match[1]);
|
||||||
|
const to = slugify(match[2]);
|
||||||
|
if (!from || !to) {
|
||||||
|
errors.push(`Line ${index + 1}: "${line}" is missing a campus`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feeds.set(from, to);
|
||||||
|
});
|
||||||
|
return { feeds, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every campus its production role: which ones originate the broadcast,
|
||||||
|
* which one each of the others takes its resi from, and — the question the
|
||||||
|
* board exists to answer — who is riding on each origin.
|
||||||
|
*
|
||||||
|
* A resi campus pointed at a campus that is not on the board keeps the
|
||||||
|
* pointer and is flagged (`sourceMissing`) rather than quietly reading as
|
||||||
|
* unassigned: a typo in the admin text has to be visible, not invisible.
|
||||||
|
*/
|
||||||
|
function assignRoles(campuses, { broadcast = [], feeds = new Map() } = {}) {
|
||||||
|
const origins = new Set([...broadcast].map((slug) => slugify(slug)).filter(Boolean));
|
||||||
|
const shaped = (campuses || []).map((campus) => {
|
||||||
|
const role = origins.has(campus.slug) ? 'broadcast' : 'resi';
|
||||||
|
const source = role === 'broadcast' ? '' : feeds.get(campus.slug) || '';
|
||||||
|
return { ...campus, role, source, sourceName: '', sourceMissing: false, dependents: [] };
|
||||||
|
});
|
||||||
|
const bySlug = new Map(shaped.map((campus) => [campus.slug, campus]));
|
||||||
|
for (const campus of shaped) {
|
||||||
|
if (!campus.source) continue;
|
||||||
|
const origin = bySlug.get(campus.source);
|
||||||
|
campus.sourceName = origin ? origin.name : campus.source;
|
||||||
|
campus.sourceMissing = !origin;
|
||||||
|
if (origin) origin.dependents.push({ slug: campus.slug, name: campus.name });
|
||||||
|
}
|
||||||
|
return shaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
WEEKDAYS,
|
||||||
|
decodeEntities,
|
||||||
|
htmlToLines,
|
||||||
|
nameFromSlug,
|
||||||
|
slugify,
|
||||||
|
dayFromLine,
|
||||||
|
timesFromLine,
|
||||||
|
walkSection,
|
||||||
|
timesSection,
|
||||||
|
parseCampusPage,
|
||||||
|
discoverCampusSlugs,
|
||||||
|
parseManualCampuses,
|
||||||
|
mergeCampuses,
|
||||||
|
parseFeeds,
|
||||||
|
assignRoles,
|
||||||
|
};
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the Service Times parser.
|
||||||
|
*
|
||||||
|
* node modules/service-times/parse.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies; exits non-zero on the first failure. The
|
||||||
|
* fixtures are trimmed from the real campus pages, so each one stands for a
|
||||||
|
* shape the website actually publishes — including Apollo Beach's "Office
|
||||||
|
* Hours" block, which sits inside the Times section and must never be read
|
||||||
|
* as a service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const {
|
||||||
|
htmlToLines,
|
||||||
|
slugify,
|
||||||
|
dayFromLine,
|
||||||
|
timesFromLine,
|
||||||
|
timesSection,
|
||||||
|
parseCampusPage,
|
||||||
|
discoverCampusSlugs,
|
||||||
|
parseManualCampuses,
|
||||||
|
mergeCampuses,
|
||||||
|
parseFeeds,
|
||||||
|
assignRoles,
|
||||||
|
} = require('./parse');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed += 1;
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`FAIL ${name}\n ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page-builder wrapper every campus's Times block sits in. */
|
||||||
|
const page = (inner) => `
|
||||||
|
<div class="wpb_wrapper"><h5 class="q_title">Address</h5><p>1 Example Road</p></div>
|
||||||
|
<div class="wpb_wrapper"><h5 class="q_title">Times</h5>
|
||||||
|
${inner}
|
||||||
|
</div>
|
||||||
|
<div class="wpb_wrapper"><h5 class="q_title">Get Involved</h5><p>Give now</p></div>
|
||||||
|
<div class="footer"><p>Mon – Thu 9a – 4:30p</p></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WOONSOCKET = page('<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>');
|
||||||
|
const NORTH_ATTLEBORO = page('<p><strong>Sunday</strong></p><p>7:30a<br />9:30a<br />11:30a</p>');
|
||||||
|
const ORLANDO = page('<p><b>Sundays</b></p><p>Welcome 10:00am</p><p>Start 10:30am</p>');
|
||||||
|
const SOUTHCOAST = page('<p><strong>Sunday</strong></p><p>10:30a</p>');
|
||||||
|
const APOLLO_BEACH = page(
|
||||||
|
'<p><strong style="color:#333">Sunday</strong></p>'
|
||||||
|
+ '<p><span style="color:#333">9:00a & 11:00a</span></p>'
|
||||||
|
+ '<h5 class="q_title">Office Hours</h5><p>Mon-Wed 9:00a-4:30p</p>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const times = (html, slug) => parseCampusPage(html, { slug }).services.map((s) => s.text);
|
||||||
|
|
||||||
|
console.log('campus pages');
|
||||||
|
|
||||||
|
test('Woonsocket: two times joined by "&" on one line', () => {
|
||||||
|
assert.deepStrictEqual(times(WOONSOCKET, 'woonsocket'), ['9:00a', '11:00a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('North Attleboro: three times separated by <br>', () => {
|
||||||
|
assert.deepStrictEqual(times(NORTH_ATTLEBORO, 'north-attleboro'), ['7:30a', '9:30a', '11:30a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Southcoast: a single service', () => {
|
||||||
|
assert.deepStrictEqual(times(SOUTHCOAST, 'southcoast'), ['10:30a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Orlando: "am" spelling, one labelled time per line', () => {
|
||||||
|
const services = parseCampusPage(ORLANDO, { slug: 'orlando' }).services;
|
||||||
|
assert.deepStrictEqual(services.map((s) => s.text), ['10:00a', '10:30a']);
|
||||||
|
assert.deepStrictEqual(services.map((s) => s.label), ['Welcome', 'Start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Apollo Beach: the Office Hours block inside Times is not a service', () => {
|
||||||
|
assert.deepStrictEqual(times(APOLLO_BEACH, 'apollo-beach'), ['9:00a', '11:00a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Apollo Beach's real page lists an hours *range* under Office Hours, which the
|
||||||
|
range guard rejects on its own — so the test above passes even if the
|
||||||
|
stop-heading breaks. This fixture puts plain times there instead, where only
|
||||||
|
the stop-heading can keep them out: without it, the 2:00p becomes a service. */
|
||||||
|
const OFFICE_HOURS_PLAIN = page(
|
||||||
|
'<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>'
|
||||||
|
+ '<h5 class="q_title">Office Hours</h5><p>Mon 9:00a</p><p>Thu 2:00p</p>'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('an Office Hours block ends the service list, range or not', () => {
|
||||||
|
assert.deepStrictEqual(times(OFFICE_HOURS_PLAIN, 'apollo-beach'), ['9:00a', '11:00a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('the footer\'s main-office hours never reach the services', () => {
|
||||||
|
for (const [html, slug] of [[WOONSOCKET, 'woonsocket'], [SOUTHCOAST, 'southcoast']]) {
|
||||||
|
assert.ok(!times(html, slug).includes('4:30p'), `${slug} picked up the footer`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names and days come out as the site writes them', () => {
|
||||||
|
const campus = parseCampusPage(NORTH_ATTLEBORO, { slug: 'north-attleboro' });
|
||||||
|
assert.strictEqual(campus.name, 'North Attleboro');
|
||||||
|
assert.deepStrictEqual([...new Set(campus.services.map((s) => s.day))], ['Sunday']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ids are stable and unique — marks are keyed on them', () => {
|
||||||
|
const first = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||||||
|
const again = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||||||
|
assert.deepStrictEqual(first, ['1000-welcome', '1030-start']);
|
||||||
|
assert.deepStrictEqual(first, again);
|
||||||
|
assert.strictEqual(new Set(first).size, first.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page that no longer has a Times section reports it', () => {
|
||||||
|
const campus = parseCampusPage('<h1>Redirected elsewhere</h1>', { slug: 'norwood' });
|
||||||
|
assert.deepStrictEqual(campus.services, []);
|
||||||
|
assert.match(campus.error, /Times/);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('pieces');
|
||||||
|
|
||||||
|
test('24-hour values and sort order', () => {
|
||||||
|
const [nine] = timesFromLine('9:00a');
|
||||||
|
const [noon] = timesFromLine('12:00p');
|
||||||
|
const [midnight] = timesFromLine('12:00a');
|
||||||
|
const [evening] = timesFromLine('6:30p');
|
||||||
|
assert.strictEqual(nine.hour24, 9);
|
||||||
|
assert.strictEqual(noon.hour24, 12);
|
||||||
|
assert.strictEqual(midnight.hour24, 0);
|
||||||
|
assert.strictEqual(evening.minutes, 18 * 60 + 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an hours range is not a service list', () => {
|
||||||
|
assert.deepStrictEqual(timesFromLine('Mon-Wed 9:00a-4:30p'), []);
|
||||||
|
assert.deepStrictEqual(timesFromLine('9a – 4:30p'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prose that merely contains a number is not a time', () => {
|
||||||
|
assert.deepStrictEqual(timesFromLine('1 pastor, 2 people'), []);
|
||||||
|
assert.deepStrictEqual(timesFromLine('Apollo Beach campus'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weekday headings, singular or plural, in any case', () => {
|
||||||
|
assert.strictEqual(dayFromLine('Sunday'), 'Sunday');
|
||||||
|
assert.strictEqual(dayFromLine('SUNDAYS'), 'Sunday');
|
||||||
|
assert.strictEqual(dayFromLine(' Saturday '), 'Saturday');
|
||||||
|
assert.strictEqual(dayFromLine('Sunday 9:00a'), '');
|
||||||
|
assert.strictEqual(dayFromLine('Get Involved'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<br> and block ends become line breaks', () => {
|
||||||
|
assert.deepStrictEqual(htmlToLines('<p>7:30a<br />9:30a</p>'), ['7:30a', '9:30a']);
|
||||||
|
assert.deepStrictEqual(htmlToLines('<p>9:00a & 11:00a</p>'), ['9:00a & 11:00a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Times slice stops at Get Involved', () => {
|
||||||
|
const section = timesSection(WOONSOCKET);
|
||||||
|
assert.ok(section.includes('9:00a'));
|
||||||
|
assert.ok(!section.includes('Give now'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('campus slugs are discovered from a sitemap', () => {
|
||||||
|
const xml = `<url><loc>https://waterschurch.org/locations/</loc></url>
|
||||||
|
<url><loc>https://waterschurch.org/locations/north-attleboro/</loc></url>
|
||||||
|
<url><loc>https://waterschurch.org/locations/norwood/</loc></url>`;
|
||||||
|
assert.deepStrictEqual(discoverCampusSlugs(xml), ['north-attleboro', 'norwood']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slugs survive punctuation and case', () => {
|
||||||
|
assert.strictEqual(slugify('North Attleboro'), 'north-attleboro');
|
||||||
|
assert.strictEqual(slugify(' Apollo Beach! '), 'apollo-beach');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('manual campuses');
|
||||||
|
|
||||||
|
test('name, day and times — day optional', () => {
|
||||||
|
const { campuses, errors } = parseManualCampuses(
|
||||||
|
'Plymouth | Sunday | 9:00a, 11:00a\nQuincy | 10:30a\n'
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(errors, []);
|
||||||
|
assert.deepStrictEqual(campuses.map((c) => c.name), ['Plymouth', 'Quincy']);
|
||||||
|
assert.deepStrictEqual(campuses[0].services.map((s) => s.text), ['9:00a', '11:00a']);
|
||||||
|
assert.deepStrictEqual(campuses[1].services.map((s) => s.day), ['Sunday']);
|
||||||
|
assert.ok(campuses[0].manual);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blank lines and # comments are skipped; a line with no time is reported', () => {
|
||||||
|
const { campuses, errors } = parseManualCampuses('\n# not yet on the site\nPlymouth | soon\n');
|
||||||
|
assert.deepStrictEqual(campuses, []);
|
||||||
|
assert.strictEqual(errors.length, 1);
|
||||||
|
assert.match(errors[0], /Line 3/);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('merging');
|
||||||
|
|
||||||
|
test('the website wins once it publishes a campus the operator added by hand', () => {
|
||||||
|
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [{ id: '0900', text: '9:00a' }], error: '' }];
|
||||||
|
const manual = parseManualCampuses('Plymouth | 10:30a').campuses;
|
||||||
|
const merged = mergeCampuses(site, manual);
|
||||||
|
assert.strictEqual(merged.length, 1);
|
||||||
|
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['9:00a']);
|
||||||
|
assert.strictEqual(merged[0].manual, false);
|
||||||
|
assert.ok(merged[0].supersededManual, 'the admin page should be able to say the line can go');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a manual campus the site does not have yet is kept', () => {
|
||||||
|
const merged = mergeCampuses([], parseManualCampuses('Plymouth | 10:30a').campuses);
|
||||||
|
assert.deepStrictEqual(merged.map((c) => c.name), ['Plymouth']);
|
||||||
|
assert.ok(merged[0].manual);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a site page that parses to nothing falls back to the manual line', () => {
|
||||||
|
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [], error: 'No "Times" section on the page' }];
|
||||||
|
const merged = mergeCampuses(site, parseManualCampuses('Plymouth | 10:30a').campuses);
|
||||||
|
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['10:30a']);
|
||||||
|
assert.strictEqual(merged[0].error, '');
|
||||||
|
assert.ok(merged[0].manual);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('resi sources');
|
||||||
|
|
||||||
|
test('campus = broadcast campus, by name or slug, with -> and : accepted', () => {
|
||||||
|
const { feeds, errors } = parseFeeds(
|
||||||
|
'Woonsocket = North Attleboro\nsouthcoast -> north-attleboro\n# a comment\n\nOrlando: Apollo Beach'
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(errors, []);
|
||||||
|
assert.strictEqual(feeds.get('woonsocket'), 'north-attleboro');
|
||||||
|
assert.strictEqual(feeds.get('southcoast'), 'north-attleboro');
|
||||||
|
assert.strictEqual(feeds.get('orlando'), 'apollo-beach');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a line that names no source is reported, not dropped in silence', () => {
|
||||||
|
const { feeds, errors } = parseFeeds('woonsocket\nsouthcoast = north-attleboro');
|
||||||
|
assert.strictEqual(feeds.size, 1);
|
||||||
|
assert.strictEqual(errors.length, 1);
|
||||||
|
assert.match(errors[0], /Line 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('roles and dependents');
|
||||||
|
|
||||||
|
/** The four campuses production runs, set up the way the booth runs them. */
|
||||||
|
const BOARD = [
|
||||||
|
{ slug: 'north-attleboro', name: 'North Attleboro', services: [{ id: '0730' }, { id: '0930' }, { id: '1130' }] },
|
||||||
|
{ slug: 'woonsocket', name: 'Woonsocket', services: [{ id: '0900' }, { id: '1100' }] },
|
||||||
|
{ slug: 'southcoast', name: 'Southcoast', services: [{ id: '1030' }] },
|
||||||
|
{ slug: 'apollo-beach', name: 'Apollo Beach', services: [{ id: '0900' }, { id: '1100' }] },
|
||||||
|
];
|
||||||
|
const SETUP = {
|
||||||
|
broadcast: ['north-attleboro', 'apollo-beach'],
|
||||||
|
feeds: parseFeeds('woonsocket = north-attleboro\nsouthcoast = north-attleboro').feeds,
|
||||||
|
};
|
||||||
|
const byName = (campuses, name) => campuses.find((c) => c.name === name);
|
||||||
|
|
||||||
|
test('the broadcast campuses originate and take resi from nobody', () => {
|
||||||
|
const roles = assignRoles(BOARD, SETUP);
|
||||||
|
for (const name of ['North Attleboro', 'Apollo Beach']) {
|
||||||
|
assert.strictEqual(byName(roles, name).role, 'broadcast', name);
|
||||||
|
assert.strictEqual(byName(roles, name).source, '', `${name} should have no source`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every other campus takes resi from the campus it is mapped to', () => {
|
||||||
|
const roles = assignRoles(BOARD, SETUP);
|
||||||
|
for (const name of ['Woonsocket', 'Southcoast']) {
|
||||||
|
const campus = byName(roles, name);
|
||||||
|
assert.strictEqual(campus.role, 'resi', name);
|
||||||
|
assert.strictEqual(campus.source, 'north-attleboro', name);
|
||||||
|
assert.strictEqual(campus.sourceName, 'North Attleboro', name);
|
||||||
|
assert.strictEqual(campus.sourceMissing, false, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an origin lists who is riding on it — the answer the board exists for', () => {
|
||||||
|
const roles = assignRoles(BOARD, SETUP);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
byName(roles, 'North Attleboro').dependents.map((d) => d.name),
|
||||||
|
['Woonsocket', 'Southcoast']
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(byName(roles, 'Apollo Beach').dependents, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a source that is not on the board is flagged, not read as unassigned', () => {
|
||||||
|
const roles = assignRoles(BOARD, {
|
||||||
|
broadcast: ['north-attleboro'],
|
||||||
|
feeds: parseFeeds('woonsocket = attleboro-north').feeds, // a typo
|
||||||
|
});
|
||||||
|
const woonsocket = byName(roles, 'Woonsocket');
|
||||||
|
assert.strictEqual(woonsocket.source, 'attleboro-north');
|
||||||
|
assert.strictEqual(woonsocket.sourceMissing, true);
|
||||||
|
assert.deepStrictEqual(byName(roles, 'North Attleboro').dependents, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a campus with no mapping reads as having no source at all', () => {
|
||||||
|
const roles = assignRoles(BOARD, { broadcast: ['north-attleboro'], feeds: new Map() });
|
||||||
|
assert.strictEqual(byName(roles, 'Southcoast').source, '');
|
||||||
|
assert.strictEqual(byName(roles, 'Southcoast').sourceMissing, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignRoles leaves the campuses it was handed alone', () => {
|
||||||
|
const input = [{ slug: 'woonsocket', name: 'Woonsocket', services: [] }];
|
||||||
|
assignRoles(input, SETUP);
|
||||||
|
assert.strictEqual(input[0].role, undefined, 'the caller’s objects must not be rewritten');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} tests passed`);
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service Times — server part.
|
||||||
|
*
|
||||||
|
* Owns the one connection to the church website, keeps every campus's Sunday
|
||||||
|
* service times in memory, and fans them — together with the production marks
|
||||||
|
* an operator puts on a service — out to every tile over SSE. Browsers never
|
||||||
|
* talk to the website.
|
||||||
|
*
|
||||||
|
* GET /state → { state } (instant paint for new tiles)
|
||||||
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
* POST /mark → mark one service (replay / do not use / issue)
|
||||||
|
* POST /clear → drop every mark (a fresh week)
|
||||||
|
* POST /refresh → re-read the website now
|
||||||
|
* GET /campuses → { options } — the campuses in play, for the admin
|
||||||
|
* broadcast picker and a tile's campus list
|
||||||
|
* GET /discovered → { options } — every campus page the website publishes,
|
||||||
|
* for the admin "Campuses" picker
|
||||||
|
*
|
||||||
|
* Why the marks live here and not in a tile's settings: a mark is the booth's
|
||||||
|
* shared answer to "is this service usable?", and every campus watching the
|
||||||
|
* board has to see the same one. Per-tile settings are per browser, so they
|
||||||
|
* would let two people disagree about whether the 9:30 is going out. Marks
|
||||||
|
* are written to the ProdDash data directory so a restart mid-morning does
|
||||||
|
* not lose them, and they expire on their own (admin "Marks") so last week's
|
||||||
|
* never greet a new Sunday.
|
||||||
|
*
|
||||||
|
* The website is the source of truth for times. Manual campuses (admin) are a
|
||||||
|
* stopgap for a campus the site has not published yet; the moment the site
|
||||||
|
* carries it, the site's times take over and the admin page says the manual
|
||||||
|
* line can go.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const {
|
||||||
|
parseCampusPage,
|
||||||
|
discoverCampusSlugs,
|
||||||
|
parseManualCampuses,
|
||||||
|
mergeCampuses,
|
||||||
|
parseFeeds,
|
||||||
|
assignRoles,
|
||||||
|
slugify,
|
||||||
|
} = require('./parse');
|
||||||
|
|
||||||
|
/** Abort a hung page read well before the refresh cadence. */
|
||||||
|
const REQUEST_TIMEOUT_MS = 12000;
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
/** Fastest allowed re-read: service times change a few times a year. */
|
||||||
|
const MIN_REFRESH_MINUTES = 5;
|
||||||
|
/** Backoff after a failed read — keep the last good times on screen meanwhile. */
|
||||||
|
const RETRY_MS = 60000;
|
||||||
|
/** Guard against a redirect loop or a runaway campus list. */
|
||||||
|
const MAX_CAMPUSES = 40;
|
||||||
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']);
|
||||||
|
const NOTE_LIMIT = 280;
|
||||||
|
|
||||||
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function sendJson(res, code, body) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let data = '';
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > BODY_LIMIT_BYTES) {
|
||||||
|
reject(new Error('Request body too large.'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!data.trim()) return resolve({});
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(data);
|
||||||
|
resolve(body && typeof body === 'object' ? body : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Malformed JSON.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||||
|
function guard(fn) {
|
||||||
|
return (req, res) => {
|
||||||
|
Promise.resolve(fn(req, res)).catch((err) => {
|
||||||
|
try {
|
||||||
|
sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) });
|
||||||
|
} catch { /* the client is already gone */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where ProdDash keeps per-machine state, resolved the way the shell does.
|
||||||
|
* Never the checkout: the repo folder is replaced on update.
|
||||||
|
*/
|
||||||
|
function resolveDataDir() {
|
||||||
|
const root = path.join(__dirname, '..', '..');
|
||||||
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||||
|
if (fromEnv) return path.resolve(root, fromEnv);
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||||
|
}
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json');
|
||||||
|
|
||||||
|
function markKey(campus, service) {
|
||||||
|
return `${campus}::${service}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── admin config shaping ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function siteBase(config) {
|
||||||
|
const raw = String(config.siteUrl || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!raw) return '';
|
||||||
|
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSetting(value) {
|
||||||
|
if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the website ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function getText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
redirect: 'follow',
|
||||||
|
headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(`${response.status} ${response.statusText || ''}`.trim());
|
||||||
|
err.status = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every campus page the site publishes — the sitemap first, the index page as a fallback. */
|
||||||
|
async function discover(base, log) {
|
||||||
|
const sources = [];
|
||||||
|
for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) {
|
||||||
|
try {
|
||||||
|
sources.push(await getText(base + target));
|
||||||
|
if (target.includes('sitemap')) break; // a sitemap is the fuller list
|
||||||
|
} catch (err) {
|
||||||
|
log(`discovery: ${target} — ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sources.length) return [];
|
||||||
|
return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCampus(base, slug) {
|
||||||
|
try {
|
||||||
|
const html = await getText(`${base}/locations/${slug}/`);
|
||||||
|
return parseCampusPage(html, { slug });
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||||
|
services: [],
|
||||||
|
error: `Page unreadable: ${err.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── module ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const streams = new Set();
|
||||||
|
const base = siteBase(config);
|
||||||
|
|
||||||
|
const chosen = listSetting(config.campuses);
|
||||||
|
const broadcast = new Set(listSetting(config.broadcast));
|
||||||
|
const { feeds, errors: feedErrors } = parseFeeds(config.feeds);
|
||||||
|
const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual);
|
||||||
|
const retentionHours = Math.max(0, Number(config.retentionHours) || 0);
|
||||||
|
const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000;
|
||||||
|
|
||||||
|
let marks = new Map();
|
||||||
|
let marksWritable = true;
|
||||||
|
let discovered = [];
|
||||||
|
let siteCampuses = [];
|
||||||
|
let reachable = false;
|
||||||
|
let lastError = base ? '' : 'No website address configured';
|
||||||
|
let lastFetched = 0;
|
||||||
|
let timer = null;
|
||||||
|
let stopped = false;
|
||||||
|
let lastFrame = '';
|
||||||
|
|
||||||
|
/* ── marks: load, save, expire ──────────────────────────────────── */
|
||||||
|
|
||||||
|
function loadMarks() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8'));
|
||||||
|
for (const [key, value] of Object.entries(saved?.marks || {})) {
|
||||||
|
if (value && typeof value === 'object') marks.set(key, value);
|
||||||
|
}
|
||||||
|
log(`${marks.size} mark(s) restored`);
|
||||||
|
} catch {
|
||||||
|
/* no marks yet — the usual case */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMarks() {
|
||||||
|
const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() };
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true });
|
||||||
|
const tmp = `${MARKS_FILE}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n');
|
||||||
|
fs.renameSync(tmp, MARKS_FILE);
|
||||||
|
marksWritable = true;
|
||||||
|
} catch (err) {
|
||||||
|
// Never fall back to writing inside the checkout: the marks stay in
|
||||||
|
// memory and health() says they will not survive a restart.
|
||||||
|
if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`);
|
||||||
|
marksWritable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop marks older than the retention window. Returns true if any went. */
|
||||||
|
function expireMarks() {
|
||||||
|
if (!retentionHours) return false;
|
||||||
|
const cutoff = Date.now() - retentionHours * 3600000;
|
||||||
|
let dropped = false;
|
||||||
|
for (const [key, mark] of marks) {
|
||||||
|
if (!(Number(mark?.at) > cutoff)) {
|
||||||
|
marks.delete(key);
|
||||||
|
dropped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dropped) saveMarks();
|
||||||
|
return dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the view every tile draws ──────────────────────────────────── */
|
||||||
|
|
||||||
|
function campusList() {
|
||||||
|
const wanted = chosen.length
|
||||||
|
? siteCampuses.filter((c) => chosen.includes(c.slug))
|
||||||
|
: siteCampuses;
|
||||||
|
const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds });
|
||||||
|
|
||||||
|
// Marks ride along on the service they belong to.
|
||||||
|
for (const campus of merged) {
|
||||||
|
campus.services = campus.services.map((service) => {
|
||||||
|
const mark = marks.get(markKey(campus.slug, service.id)) || null;
|
||||||
|
return { ...service, mark };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return {
|
||||||
|
configured: Boolean(base),
|
||||||
|
reachable,
|
||||||
|
lastError,
|
||||||
|
lastFetched,
|
||||||
|
siteUrl: base,
|
||||||
|
refreshMinutes: refreshMs / 60000,
|
||||||
|
retentionHours,
|
||||||
|
marksWritable,
|
||||||
|
campuses: campusList(),
|
||||||
|
discovered,
|
||||||
|
manualErrors,
|
||||||
|
feedErrors,
|
||||||
|
now: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastState({ force = false } = {}) {
|
||||||
|
if (!streams.size) return;
|
||||||
|
const view = snapshot();
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.stringify(view);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `now` changes every call; compare without it so idle tiles stay idle.
|
||||||
|
const compare = frame.replace(/,"now":\d+/, '');
|
||||||
|
if (!force && compare === lastFrame) return;
|
||||||
|
lastFrame = compare;
|
||||||
|
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the refresh loop ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (!base) {
|
||||||
|
reachable = false;
|
||||||
|
lastError = 'No website address configured';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const found = await discover(base, log);
|
||||||
|
if (found.length) discovered = found;
|
||||||
|
const slugs = chosen.length ? chosen : discovered;
|
||||||
|
if (!slugs.length) throw new Error('No campus pages found on the site');
|
||||||
|
|
||||||
|
const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug)));
|
||||||
|
const usable = read.filter((campus) => campus.services.length);
|
||||||
|
|
||||||
|
// A total failure keeps the last good times on screen — a site outage
|
||||||
|
// must not blank the board in the middle of a service.
|
||||||
|
if (!usable.length && siteCampuses.length) {
|
||||||
|
throw new Error(read[0]?.error || 'No service times found on the site');
|
||||||
|
}
|
||||||
|
siteCampuses = read;
|
||||||
|
reachable = true;
|
||||||
|
lastFetched = Date.now();
|
||||||
|
const broken = read.filter((campus) => campus.error);
|
||||||
|
lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : '';
|
||||||
|
log(`read ${usable.length}/${read.length} campus page(s) from ${base}`);
|
||||||
|
} catch (err) {
|
||||||
|
reachable = false;
|
||||||
|
lastError = err?.message || String(err);
|
||||||
|
log(`refresh failed: ${lastError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(delay) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (stopped) return;
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
await refresh();
|
||||||
|
expireMarks();
|
||||||
|
broadcastState();
|
||||||
|
schedule(reachable ? refreshMs : RETRY_MS);
|
||||||
|
}, delay);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMarks();
|
||||||
|
expireMarks();
|
||||||
|
if (base) {
|
||||||
|
log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`);
|
||||||
|
} else {
|
||||||
|
log('no website address configured — set one in /admin');
|
||||||
|
}
|
||||||
|
// First read immediately, then on the configured cadence.
|
||||||
|
schedule(0);
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
expireMarks();
|
||||||
|
broadcastState();
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
current = {
|
||||||
|
streams,
|
||||||
|
snapshot,
|
||||||
|
broadcastState,
|
||||||
|
refreshNow: async () => {
|
||||||
|
await refresh();
|
||||||
|
broadcastState({ force: true });
|
||||||
|
},
|
||||||
|
setMark(campusSlug, serviceId, patch) {
|
||||||
|
const key = markKey(slugify(campusSlug), String(serviceId || ''));
|
||||||
|
const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok';
|
||||||
|
if (status === 'ok' && !patch.note && !patch.source) {
|
||||||
|
marks.delete(key);
|
||||||
|
} else {
|
||||||
|
marks.set(key, {
|
||||||
|
status,
|
||||||
|
note: String(patch.note || '').slice(0, NOTE_LIMIT),
|
||||||
|
source: String(patch.source || '').slice(0, 120),
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveMarks();
|
||||||
|
broadcastState({ force: true });
|
||||||
|
},
|
||||||
|
clearMarks() {
|
||||||
|
const had = marks.size;
|
||||||
|
marks.clear();
|
||||||
|
saveMarks();
|
||||||
|
broadcastState({ force: true });
|
||||||
|
return had;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
if (!base) return { status: 'error', message: 'No website address configured' };
|
||||||
|
const notes = [];
|
||||||
|
if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable');
|
||||||
|
if (manualErrors.length) notes.push(manualErrors[0]);
|
||||||
|
if (feedErrors.length) notes.push(feedErrors[0]);
|
||||||
|
const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name);
|
||||||
|
if (superseded.length) {
|
||||||
|
notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`);
|
||||||
|
}
|
||||||
|
const suffix = notes.length ? ` — ${notes.join('; ')}` : '';
|
||||||
|
if (!reachable) {
|
||||||
|
return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` };
|
||||||
|
}
|
||||||
|
const count = campusList().reduce((sum, c) => sum + c.services.length, 0);
|
||||||
|
const warn = lastError || notes.length;
|
||||||
|
return {
|
||||||
|
status: warn ? 'connecting' : 'ok',
|
||||||
|
message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board, plus one entry per campus. A campus booth wants its own times
|
||||||
|
* and the service it is taking resi from — not the whole network — so those
|
||||||
|
* entries fix the campus and drop the campus picker from their gear.
|
||||||
|
*/
|
||||||
|
tiles() {
|
||||||
|
const board = {
|
||||||
|
id: 'board',
|
||||||
|
name: 'Service Times',
|
||||||
|
description: 'Every campus, its services, and what is marked for replay or unusable',
|
||||||
|
defaultSize: { w: 6, h: 5 },
|
||||||
|
minSize: { w: 3, h: 3 },
|
||||||
|
settings: {},
|
||||||
|
};
|
||||||
|
const campuses = current ? current.snapshot().campuses : [];
|
||||||
|
return [
|
||||||
|
board,
|
||||||
|
...campuses.map((campus) => ({
|
||||||
|
id: campus.slug,
|
||||||
|
name: campus.name,
|
||||||
|
description: `${campus.name} service times`,
|
||||||
|
defaultSize: { w: 3, h: 4 },
|
||||||
|
minSize: { w: 2, h: 2 },
|
||||||
|
settings: { campuses: [campus.slug] },
|
||||||
|
instanceSchema: {
|
||||||
|
showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' },
|
||||||
|
showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' },
|
||||||
|
showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' },
|
||||||
|
showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' },
|
||||||
|
showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' },
|
||||||
|
showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' },
|
||||||
|
},
|
||||||
|
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
routes() {
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
sendJson(res, 200, { state: current ? current.snapshot() : null });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`);
|
||||||
|
if (!current) return void res.end();
|
||||||
|
const { streams } = current;
|
||||||
|
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||||
|
// not queue frames forever; keepalive probes make the OS notice.
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /mark': guard(async (req, res) => {
|
||||||
|
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const campus = String(body.campus || '').trim();
|
||||||
|
const service = String(body.service || '').trim();
|
||||||
|
if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' });
|
||||||
|
current.setMark(campus, service, {
|
||||||
|
status: body.status,
|
||||||
|
note: body.note,
|
||||||
|
source: body.source,
|
||||||
|
});
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}),
|
||||||
|
|
||||||
|
'POST /clear': guard(async (req, res) => {
|
||||||
|
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||||
|
sendJson(res, 200, { ok: true, cleared: current.clearMarks() });
|
||||||
|
}),
|
||||||
|
|
||||||
|
'POST /refresh': guard(async (req, res) => {
|
||||||
|
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||||
|
await current.refreshNow();
|
||||||
|
sendJson(res, 200, { ok: true, state: current.snapshot() });
|
||||||
|
}),
|
||||||
|
|
||||||
|
'GET /campuses': (req, res) => {
|
||||||
|
const campuses = current ? current.snapshot().campuses : [];
|
||||||
|
sendJson(res, 200, {
|
||||||
|
options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /discovered': (req, res) => {
|
||||||
|
const state = current ? current.snapshot() : null;
|
||||||
|
const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name]));
|
||||||
|
const slugs = state?.discovered?.length ? state.discovered : [...names.keys()];
|
||||||
|
sendJson(res, 200, {
|
||||||
|
options: slugs.map((slug) => ({
|
||||||
|
value: slug,
|
||||||
|
label: names.get(slug)
|
||||||
|
|| slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/* Service Times — scoped under its tiles via [data-module="service-times"],
|
||||||
|
styled with the shell's CSS variables only so themes reach into it. */
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-board {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-grid-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── a campus ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-campus {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, var(--text) 8%);
|
||||||
|
padding: 7px 8px 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-is-broadcast {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-campus-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-campus-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.02em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-role {
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-role-broadcast {
|
||||||
|
color: var(--accent-text, var(--text));
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-role-manual {
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-role-unset {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-feeds {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 0.76em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── a service ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-left: 3px solid color-mix(in srgb, var(--text) 22%, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-row:hover,
|
||||||
|
[data-module="service-times"] .st-row:focus-visible {
|
||||||
|
border-color: var(--border);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-row.st-open {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-time {
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-day {
|
||||||
|
font-size: 0.74em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-chips {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-chip {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-chip-issue,
|
||||||
|
[data-module="service-times"] .st-chip-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
background: color-mix(in srgb, var(--warn) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-chip-replay {
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-chip-skip {
|
||||||
|
color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-chip-source,
|
||||||
|
[data-module="service-times"] .st-chip-quiet {
|
||||||
|
color: var(--muted);
|
||||||
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marked services read at a glance from across the booth. */
|
||||||
|
[data-module="service-times"] .st-row.st-issue { border-left-color: var(--warn); }
|
||||||
|
[data-module="service-times"] .st-row.st-replay { border-left-color: var(--accent); }
|
||||||
|
[data-module="service-times"] .st-row.st-skip {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="service-times"] .st-row.st-skip .st-time {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
[data-module="service-times"] .st-row.st-affected {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-note {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── messages ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-empty,
|
||||||
|
[data-module="service-times"] .st-updated {
|
||||||
|
margin: 4px 2px 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-warn {
|
||||||
|
margin: 6px 2px 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the mark editor ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-pick {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-pick:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-pick.active,
|
||||||
|
[data-module="service-times"] .st-save {
|
||||||
|
color: var(--accent-text, var(--text));
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-label {
|
||||||
|
font-size: 0.72em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-input {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="service-times"] .st-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
+121
-42
@@ -17,6 +17,21 @@
|
|||||||
|
|
||||||
const FLOOR = -200;
|
const FLOOR = -200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dead input reads as a number, and a number on a wall is believed. Every
|
||||||
|
* tile therefore refuses to draw a level while the server says the input is
|
||||||
|
* digitally silent, and says why instead.
|
||||||
|
*/
|
||||||
|
function silentFor(payload) {
|
||||||
|
return payload && payload.silentSec ? payload.silentSec : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function silentText(sec) {
|
||||||
|
if (sec < 90) return `No audio — ${sec} s`;
|
||||||
|
const m = Math.round(sec / 60);
|
||||||
|
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Retention per frame for the RTA's smoothing setting. */
|
/** Retention per frame for the RTA's smoothing setting. */
|
||||||
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||||
|
|
||||||
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.setStatus('error', payload.error);
|
moduleApi.setStatus('error', payload.error);
|
||||||
} else if (!payload.frame) {
|
} else if (!payload.frame) {
|
||||||
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||||
|
} else if (silentFor(payload)) {
|
||||||
|
moduleApi.setStatus('error',
|
||||||
|
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
|
||||||
} else if (!payload.calibrated) {
|
} else if (!payload.calibrated) {
|
||||||
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||||
} else {
|
} else {
|
||||||
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
|
|||||||
render(payload) {
|
render(payload) {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const ch = pickChannel(payload, s);
|
const ch = pickChannel(payload, s);
|
||||||
|
const quiet = silentFor(payload);
|
||||||
|
if (quiet && ch) {
|
||||||
|
num.textContent = '––.–';
|
||||||
|
unit.textContent = '';
|
||||||
|
sub.hidden = false;
|
||||||
|
sub.textContent = silentText(quiet);
|
||||||
|
box.dataset.level = 'dead';
|
||||||
|
fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!payload || payload.error || !ch) {
|
if (!payload || payload.error || !ch) {
|
||||||
num.textContent = '––.–';
|
num.textContent = '––.–';
|
||||||
sub.textContent = payload && payload.error ? payload.error : '';
|
sub.textContent = payload && payload.error ? payload.error : '';
|
||||||
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
|
const quiet = silentFor(payload);
|
||||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||||
const showPeak = s.peakHold !== false;
|
const showPeak = s.peakHold !== false;
|
||||||
const lo = payload.calibrated ? 40 : -80;
|
const lo = payload.calibrated ? 40 : -80;
|
||||||
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
|
|||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
const value = readValue(c, { averaging: s.averaging });
|
const value = readValue(c, { averaging: s.averaging });
|
||||||
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||||
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
|
el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
|
||||||
const peak = el.querySelector('.spl-bar-peak');
|
const peak = el.querySelector('.spl-bar-peak');
|
||||||
peak.style.display = showPeak ? '' : 'none';
|
peak.style.display = showPeak ? '' : 'none';
|
||||||
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
||||||
el.querySelector('.spl-bar-value').textContent = fmt(value);
|
el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
|
||||||
el.dataset.level = !payload.calibrated ? 'idle'
|
el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
|
||||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||||
el.classList.toggle('clip', !!c.clip);
|
el.classList.toggle('clip', !!c.clip);
|
||||||
}
|
}
|
||||||
@@ -321,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
|
|||||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||||
const canvas = root.querySelector('.spl-canvas');
|
const canvas = root.querySelector('.spl-canvas');
|
||||||
const ctx = canvas.getContext('2d', { alpha: false });
|
const ctx = canvas.getContext('2d', { alpha: false });
|
||||||
let painted = false; // spectrogram: has the background been laid down
|
/**
|
||||||
|
* The spectrogram's scrolling history, on its own surface.
|
||||||
|
*
|
||||||
|
* It used to scroll by copying the visible canvas onto itself, which also
|
||||||
|
* copied whatever had been drawn on top of it — so every frame dragged the
|
||||||
|
* frequency labels and gridlines one step further along and left a smear.
|
||||||
|
* Only measured pixels belong on a surface that scrolls; annotations are
|
||||||
|
* drawn fresh over the top of it, every frame, and never into it.
|
||||||
|
*/
|
||||||
|
let history = null;
|
||||||
|
let historyCtx = null;
|
||||||
|
let painted = false; // spectrogram: has the history been cleared yet
|
||||||
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||||
let smoothedFor = '';
|
let smoothedFor = '';
|
||||||
let axisBtn = null;
|
let axisBtn = null;
|
||||||
@@ -389,10 +429,12 @@ function spectrumView(root, moduleApi, scrolling) {
|
|||||||
const muted = themeColor(root, '--muted', '#888');
|
const muted = themeColor(root, '--muted', '#888');
|
||||||
const border = themeColor(root, '--border', '#333');
|
const border = themeColor(root, '--border', '#333');
|
||||||
|
|
||||||
if (!payload || !payload.frame || !payload.frame.bands) {
|
const quiet = silentFor(payload);
|
||||||
|
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
|
||||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
||||||
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
|
ctx.fillText(quiet ? silentText(quiet)
|
||||||
|
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
|
||||||
painted = false;
|
painted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -466,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
|
|||||||
// ── spectrogram ──
|
// ── spectrogram ──
|
||||||
const timeX = axis() === 'x';
|
const timeX = axis() === 'x';
|
||||||
const colW = 2;
|
const colW = 2;
|
||||||
const gutter = showLabels ? axisPad : 0;
|
// A label along the bottom needs only its line height; one down the
|
||||||
// Frequency occupies whichever axis time does not.
|
// side has to fit "12.5k" beside the plot rather than on top of it.
|
||||||
const freqExtent = timeX ? h - gutter : w - gutter;
|
const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
|
||||||
const timeExtent = timeX ? w : h;
|
// Labels belong beside the frequency axis: down the left when frequency
|
||||||
|
// runs vertically, along the bottom when it runs horizontally.
|
||||||
|
const plotX = timeX ? gutter : 0;
|
||||||
|
const plotW = timeX ? w - gutter : w;
|
||||||
|
const plotH = timeX ? h : h - gutter;
|
||||||
|
const freqExtent = timeX ? plotH : plotW;
|
||||||
|
const timeExtent = timeX ? plotW : plotH;
|
||||||
|
if (plotW < 4 || plotH < 4) return;
|
||||||
|
|
||||||
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
|
// The history surface is sized to the plot, in device pixels, and is
|
||||||
|
// thrown away whenever that geometry changes — a scroll buffer means
|
||||||
// Source rect in device pixels, destination in CSS pixels: the context
|
// nothing once it is being stretched into a different shape.
|
||||||
// carries a devicePixelRatio transform but the canvas as an image
|
const dpr = window.devicePixelRatio || 1;
|
||||||
// source does not, and letting drawImage infer them scales the picture
|
const needW = Math.max(1, Math.round(plotW * dpr));
|
||||||
// up and pushes every column off the canvas.
|
const needH = Math.max(1, Math.round(plotH * dpr));
|
||||||
if (timeX) {
|
if (!history || history.width !== needW || history.height !== needH) {
|
||||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
history = document.createElement('canvas');
|
||||||
} else {
|
history.width = needW;
|
||||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
|
history.height = needH;
|
||||||
|
historyCtx = history.getContext('2d', { alpha: false });
|
||||||
|
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
painted = false;
|
||||||
}
|
}
|
||||||
|
if (!painted) {
|
||||||
|
historyCtx.fillStyle = bg;
|
||||||
|
historyCtx.fillRect(0, 0, plotW, plotH);
|
||||||
|
painted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the history, then add the newest column. The source rectangle
|
||||||
|
// is in device pixels and the destination in CSS pixels, because the
|
||||||
|
// context carries the dpr transform but the canvas as an image source
|
||||||
|
// does not.
|
||||||
|
historyCtx.drawImage(history, 0, 0, history.width, history.height,
|
||||||
|
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
|
||||||
|
|
||||||
const bandExtent = freqExtent / values.length;
|
const bandExtent = freqExtent / values.length;
|
||||||
for (let i = 0; i < values.length; i++) {
|
for (let i = 0; i < values.length; i++) {
|
||||||
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
|
historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||||
if (timeX) {
|
if (timeX) {
|
||||||
// Low frequencies at the bottom, as every spectrogram has them.
|
// Low frequencies at the bottom, as every spectrogram has them.
|
||||||
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||||
colW, Math.ceil(bandExtent) + 1);
|
colW, Math.ceil(bandExtent) + 1);
|
||||||
} else {
|
} else {
|
||||||
// Low frequencies at the left.
|
// Low frequencies at the left.
|
||||||
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
|
historyCtx.fillRect(i * bandExtent, timeExtent - colW,
|
||||||
Math.ceil(bandExtent) + 1, colW);
|
Math.ceil(bandExtent) + 1, colW);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (grid || showLabels) {
|
// Repaint the visible canvas from scratch every frame: history first,
|
||||||
const posOf = (f) => {
|
// annotations over it. Nothing drawn here can ever reach the scroll.
|
||||||
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
|
ctx.drawImage(history, 0, 0, history.width, history.height,
|
||||||
};
|
plotX, 0, plotW, plotH);
|
||||||
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
|
|
||||||
ctx.fillStyle = bg;
|
if (!grid && !showLabels) return;
|
||||||
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
|
|
||||||
else ctx.fillRect(0, 0, gutter, h);
|
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
const posOf = (f) => {
|
||||||
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
|
const t = Math.log2(f / freqs[0]) / span;
|
||||||
|
return timeX ? plotH - t * plotH : plotX + t * plotW;
|
||||||
|
};
|
||||||
|
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
|
||||||
|
ctx.beginPath();
|
||||||
for (const l of labels) {
|
for (const l of labels) {
|
||||||
if (timeX) {
|
if (timeX) {
|
||||||
const y = Math.round(l.pos) + 0.5;
|
const y = Math.round(l.pos) + 0.5;
|
||||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
ctx.moveTo(plotX, y); ctx.lineTo(w, y);
|
||||||
} else {
|
} else {
|
||||||
const x = Math.round(l.pos) + 0.5;
|
const x = Math.round(l.pos) + 0.5;
|
||||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.stroke(); ctx.globalAlpha = 1;
|
ctx.stroke(); ctx.globalAlpha = 1;
|
||||||
ctx.textAlign = timeX ? 'left' : 'center';
|
}
|
||||||
for (const l of showLabels ? labels : []) {
|
|
||||||
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
|
if (showLabels) {
|
||||||
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||||
|
ctx.textAlign = timeX ? 'right' : 'center';
|
||||||
|
for (const l of labels) {
|
||||||
|
if (timeX) {
|
||||||
|
ctx.fillText(hzLabel(l.f), gutter - 2,
|
||||||
|
Math.max(7, Math.min(h - 2, l.pos + 3)));
|
||||||
|
} else {
|
||||||
|
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.3.1",
|
"version": "0.4.1",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
|
|||||||
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
const PING_MS = 15000;
|
const PING_MS = 15000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this, unweighted, the input is not quiet — it is not there.
|
||||||
|
*
|
||||||
|
* A real input always carries something: a microphone in a silent room still
|
||||||
|
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
|
||||||
|
* a stream of actual zeros reaches the capture tool's -200 floor, and that
|
||||||
|
* means the device handed us nothing at all.
|
||||||
|
*/
|
||||||
|
const SILENCE_DBFS = -150;
|
||||||
|
/** How long that has to hold before saying so, so a gap between buffers or a
|
||||||
|
device changing sample rate mid-stream doesn't cry wolf. */
|
||||||
|
const SILENCE_GRACE_MS = 5000;
|
||||||
|
|
||||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||||
let state = null;
|
let state = null;
|
||||||
|
|
||||||
@@ -100,6 +113,7 @@ module.exports = {
|
|||||||
meta: null,
|
meta: null,
|
||||||
error: '',
|
error: '',
|
||||||
streams: new Set(),
|
streams: new Set(),
|
||||||
|
silentSince: null,
|
||||||
listener: null,
|
listener: null,
|
||||||
lastSent: '',
|
lastSent: '',
|
||||||
pinger: null,
|
pinger: null,
|
||||||
@@ -143,6 +157,12 @@ module.exports = {
|
|||||||
state.error = '';
|
state.error = '';
|
||||||
state.frame = update.frame;
|
state.frame = update.frame;
|
||||||
state.meta = update.meta;
|
state.meta = update.meta;
|
||||||
|
// Digital silence on every channel is a dead input, not a reading.
|
||||||
|
// Saying "ok" through this is what made a stopped Dante device look
|
||||||
|
// like a frozen module for a day and a half.
|
||||||
|
const dead = update.frame.ch.every(
|
||||||
|
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
|
||||||
|
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
|
||||||
broadcast();
|
broadcast();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -159,6 +179,11 @@ module.exports = {
|
|||||||
health() {
|
health() {
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
|
const quiet = silentMs();
|
||||||
|
if (quiet > SILENCE_GRACE_MS) {
|
||||||
|
return { status: 'error',
|
||||||
|
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
|
||||||
|
}
|
||||||
const n = state.channels.length;
|
const n = state.channels.length;
|
||||||
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||||
return state.calibrated
|
return state.calibrated
|
||||||
@@ -351,10 +376,26 @@ module.exports = {
|
|||||||
|
|
||||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** How long every channel has been at digital silence, in ms. */
|
||||||
|
function silentMs() {
|
||||||
|
return state && state.silentSince ? Date.now() - state.silentSince : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(ms) {
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
if (s < 90) return `${s} s`;
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
|
||||||
|
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
|
||||||
|
// changed every frame would make the fan-out's change check useless.
|
||||||
|
const quiet = silentMs();
|
||||||
return {
|
return {
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
|
||||||
calibrated: state.calibrated,
|
calibrated: state.calibrated,
|
||||||
channels: state.channels,
|
channels: state.channels,
|
||||||
weighting: state.meta ? state.meta.weighting : '',
|
weighting: state.meta ? state.meta.weighting : '',
|
||||||
|
|||||||
@@ -57,6 +57,11 @@
|
|||||||
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
||||||
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
||||||
|
|
||||||
|
/* A dead input reads danger, not muted: silence that is a fault should not
|
||||||
|
look like silence that is a quiet room. */
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
|
||||||
|
|
||||||
/* ── level bars ───────────────────────────────────────────────────── */
|
/* ── level bars ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
[data-module="spl-meter"] .spl-bars {
|
[data-module="spl-meter"] .spl-bars {
|
||||||
@@ -100,6 +105,7 @@
|
|||||||
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
||||||
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
||||||
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
||||||
|
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
|
||||||
|
|
||||||
/* Peak hold sits on the track rather than inside the fill, so it stays
|
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||||
visible when the bar falls away underneath it. */
|
visible when the bar falls away underneath it. */
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+126
-7
@@ -5,7 +5,18 @@
|
|||||||
settings) autosaves to this browser's localStorage — every browser is fully
|
settings) autosaves to this browser's localStorage — every browser is fully
|
||||||
independent. Modules render inside tile bodies and never touch the shell. */
|
independent. Modules render inside tile bodies and never touch the shell. */
|
||||||
|
|
||||||
const COLS = 12;
|
/* ── grid density ─────────────────────────────────────────────────────
|
||||||
|
How many columns the board is cut into. Twelve is the default; a finer
|
||||||
|
grid lets tiles be smaller and more of them fit across (a 4K wall), a
|
||||||
|
coarser one suits a small screen read close up. It is this browser's
|
||||||
|
choice — two displays at one location may want different densities — so
|
||||||
|
it saves with the layout, local and named, and a named layout comes back
|
||||||
|
at the density it was arranged on. Row height follows the column width, so
|
||||||
|
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
|
||||||
|
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
|
||||||
|
const GRID_DEFAULT = 12;
|
||||||
|
const CELL_H_DEFAULT = 72; // px per row at the default density
|
||||||
|
let COLS = GRID_DEFAULT;
|
||||||
const LS_LAYOUT = 'proddash:layout';
|
const LS_LAYOUT = 'proddash:layout';
|
||||||
|
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
@@ -224,7 +235,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
|||||||
|
|
||||||
function saveLayout() {
|
function saveLayout() {
|
||||||
try {
|
try {
|
||||||
const next = JSON.stringify({ v: 1, autoHideHeads, tiles });
|
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
|
||||||
const current = localStorage.getItem(LS_LAYOUT);
|
const current = localStorage.getItem(LS_LAYOUT);
|
||||||
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||||
localStorage.setItem(LS_LAYOUT, next);
|
localStorage.setItem(LS_LAYOUT, next);
|
||||||
@@ -275,6 +286,87 @@ function newId() {
|
|||||||
|
|
||||||
/* ── grid geometry & collision ──────────────────────────────────────── */
|
/* ── grid geometry & collision ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function normalizeGridCols(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellHeightFor(cols) {
|
||||||
|
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cut the board into `cols` columns. Tiles keep their grid units — the
|
||||||
|
board gets denser or coarser and everything on it changes size with it.
|
||||||
|
A tile now past the right edge is pulled back in, and one that would then
|
||||||
|
sit on another moves to the first free spot instead. */
|
||||||
|
function setGridCols(value, { save = true } = {}) {
|
||||||
|
const next = normalizeGridCols(value);
|
||||||
|
const changed = next !== COLS;
|
||||||
|
COLS = next;
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||||
|
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
|
||||||
|
if (changed) {
|
||||||
|
// A coarser board may not have room for every tile where it was. Settle
|
||||||
|
// them in reading order: pull a tile past the right edge back in, and
|
||||||
|
// give one that would then land on a tile already settled the first free
|
||||||
|
// spot instead — tiles never overlap here, any more than under a drag.
|
||||||
|
const settled = [];
|
||||||
|
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
|
||||||
|
tile.w = Math.max(1, Math.min(COLS, tile.w));
|
||||||
|
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
|
||||||
|
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
|
||||||
|
settled.push(tile);
|
||||||
|
applyRect(tile);
|
||||||
|
notifyResize(tile);
|
||||||
|
}
|
||||||
|
rebuildDividers();
|
||||||
|
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||||
|
}
|
||||||
|
if (save) saveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||||
|
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
|
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
|
||||||
|
closing the menu, so the board can be watched changing behind it. */
|
||||||
|
function gridStepper() {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'menu-stepper';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'menu-stepper-label';
|
||||||
|
label.textContent = 'Grid Size';
|
||||||
|
const minus = document.createElement('button');
|
||||||
|
minus.type = 'button';
|
||||||
|
minus.className = 'menu-stepper-btn';
|
||||||
|
minus.setAttribute('data-tip', 'Coarser');
|
||||||
|
minus.innerHTML = STEP_MINUS_SVG;
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.className = 'menu-stepper-value';
|
||||||
|
const plus = document.createElement('button');
|
||||||
|
plus.type = 'button';
|
||||||
|
plus.className = 'menu-stepper-btn';
|
||||||
|
plus.setAttribute('data-tip', 'Finer');
|
||||||
|
plus.innerHTML = STEP_PLUS_SVG;
|
||||||
|
const paint = () => {
|
||||||
|
const i = GRID_STEPS.indexOf(COLS);
|
||||||
|
value.textContent = String(COLS);
|
||||||
|
minus.disabled = i <= 0;
|
||||||
|
plus.disabled = i >= GRID_STEPS.length - 1;
|
||||||
|
};
|
||||||
|
const step = (dir) => {
|
||||||
|
const i = GRID_STEPS.indexOf(COLS) + dir;
|
||||||
|
if (i < 0 || i >= GRID_STEPS.length) return;
|
||||||
|
setGridCols(GRID_STEPS[i]);
|
||||||
|
paint();
|
||||||
|
};
|
||||||
|
minus.addEventListener('click', () => step(-1));
|
||||||
|
plus.addEventListener('click', () => step(1));
|
||||||
|
paint();
|
||||||
|
row.append(label, minus, value, plus);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
function cellMetrics() {
|
function cellMetrics() {
|
||||||
const style = getComputedStyle(grid);
|
const style = getComputedStyle(grid);
|
||||||
const gapRaw = parseFloat(style.columnGap);
|
const gapRaw = parseFloat(style.columnGap);
|
||||||
@@ -297,6 +389,17 @@ function isFree(rect, excludeId) {
|
|||||||
return isFreeExcept(rect, [excludeId]);
|
return isFreeExcept(rect, [excludeId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
|
||||||
|
function spotAmong(w, h, others) {
|
||||||
|
for (let y = 0; y < 400; y += 1) {
|
||||||
|
for (let x = 0; x <= COLS - w; x += 1) {
|
||||||
|
const rect = { x, y, w, h };
|
||||||
|
if (!others.some((o) => overlaps(rect, o))) return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
/** First free spot scanning top-to-bottom, left-to-right. */
|
/** First free spot scanning top-to-bottom, left-to-right. */
|
||||||
function findSpot(w, h) {
|
function findSpot(w, h) {
|
||||||
for (let y = 0; y < 200; y += 1) {
|
for (let y = 0; y < 200; y += 1) {
|
||||||
@@ -990,10 +1093,14 @@ function addTile(moduleId, entry = null) {
|
|||||||
toast(`${entry.name} is already on this dashboard`);
|
toast(`${entry.name} is already on this dashboard`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sizes are declared for the default twelve-column grid; scaled so a new
|
||||||
|
// tile lands at about the size its author meant, whatever the density here.
|
||||||
|
const scale = COLS / GRID_DEFAULT;
|
||||||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||||
|
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
|
||||||
const size = {
|
const size = {
|
||||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
|
||||||
h: Math.max(1, defaultSize?.h || 3),
|
h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
|
||||||
};
|
};
|
||||||
const spot = findSpot(size.w, size.h);
|
const spot = findSpot(size.w, size.h);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
@@ -1991,6 +2098,8 @@ function renderLayout(layout) {
|
|||||||
// "reset" can simply hand over no tiles.
|
// "reset" can simply hand over no tiles.
|
||||||
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
||||||
clearAllTiles();
|
clearAllTiles();
|
||||||
|
// Density first: the tiles are clamped and placed against it.
|
||||||
|
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
|
||||||
tiles = list.map(sanitizeTile);
|
tiles = list.map(sanitizeTile);
|
||||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||||
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||||
@@ -2057,6 +2166,13 @@ function renderAddMenu(menu) {
|
|||||||
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
|
|
||||||
|
// Density first: the one thing in here that is not a layout but the board
|
||||||
|
// the layouts are arranged on.
|
||||||
|
menu.appendChild(gridStepper());
|
||||||
|
const stepperDivider = document.createElement('div');
|
||||||
|
stepperDivider.className = 'menu-divider';
|
||||||
|
menu.appendChild(stepperDivider);
|
||||||
|
|
||||||
menu.appendChild(menuItem('Save as named layout…', async () => {
|
menu.appendChild(menuItem('Save as named layout…', async () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
if (!tiles.length) {
|
if (!tiles.length) {
|
||||||
@@ -2088,7 +2204,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
|
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||||
@@ -2136,15 +2252,17 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
// does not live-link browsers together.
|
// does not live-link browsers together.
|
||||||
let outgoing = null;
|
let outgoing = null;
|
||||||
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||||
|
const gridBefore = COLS;
|
||||||
renderLayout(body.layout);
|
renderLayout(body.layout);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
||||||
const unshown = unshownTileNames();
|
const unshown = unshownTileNames();
|
||||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||||
|
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||||
const note = unshown.length
|
const note = unshown.length
|
||||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||||
: '';
|
: '';
|
||||||
toast(`Loaded “${l.name}”: ${count}${note}`, false, {
|
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||||
ms: unshown.length ? 10000 : 7000,
|
ms: unshown.length ? 10000 : 7000,
|
||||||
action: outgoing ? {
|
action: outgoing ? {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
@@ -2300,7 +2418,8 @@ function applyFullscreenFit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearFullscreenFit() {
|
function clearFullscreenFit() {
|
||||||
grid.style.gridTemplateColumns = '';
|
// Back to this browser's density, not the stylesheet's twelve.
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||||
grid.style.gridAutoRows = '';
|
grid.style.gridAutoRows = '';
|
||||||
gridwrap.classList.remove('no-scroll');
|
gridwrap.classList.remove('no-scroll');
|
||||||
for (const tile of tiles) {
|
for (const tile of tiles) {
|
||||||
|
|||||||
@@ -187,6 +187,36 @@ body.menu-hidden #topbar {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||||
|
|
||||||
|
/* the Layouts menu's grid-density row: Grid Size − 12 + */
|
||||||
|
.menu-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.menu-stepper-label { flex: 1; }
|
||||||
|
.menu-stepper-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu-stepper-btn:hover { border-color: var(--muted); }
|
||||||
|
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
|
||||||
|
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
|
||||||
|
.menu-stepper-value {
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.menu-title {
|
.menu-title {
|
||||||
padding: 6px 10px 3px;
|
padding: 6px 10px 3px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -1619,6 +1619,7 @@ function previousLayoutPath(name) {
|
|||||||
so an old file does not haunt a new client. */
|
so an old file does not haunt a new client. */
|
||||||
|
|
||||||
const LAYOUT_VERSION = 2;
|
const LAYOUT_VERSION = 2;
|
||||||
|
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
|
||||||
const MAX_LAYOUT_TILES = 100;
|
const MAX_LAYOUT_TILES = 100;
|
||||||
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
||||||
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
||||||
@@ -1644,6 +1645,9 @@ function sanitizeLayout(raw) {
|
|||||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||||
// arranged, so it travels with the layout like the tiles themselves.
|
// arranged, so it travels with the layout like the tiles themselves.
|
||||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||||
|
// The grid density the layout was arranged on, when it says and it is one
|
||||||
|
// the shell knows; a browser loading it cuts its board the same way.
|
||||||
|
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
|
||||||
tiles: tiles.map((t) => {
|
tiles: tiles.map((t) => {
|
||||||
const tile = {
|
const tile = {
|
||||||
id: String(t?.id || ''),
|
id: String(t?.id || ''),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
headHidden: true,
|
headHidden: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) });
|
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
|
||||||
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
||||||
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
||||||
|
|
||||||
@@ -97,6 +97,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
const got = r.body.layout;
|
const got = r.body.layout;
|
||||||
assert.equal(got.v, 2, 'the file carries a schema version');
|
assert.equal(got.v, 2, 'the file carries a schema version');
|
||||||
assert.equal(got.autoHideHeads, true);
|
assert.equal(got.autoHideHeads, true);
|
||||||
|
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
|
||||||
const t = got.tiles[0];
|
const t = got.tiles[0];
|
||||||
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
||||||
assert.deepEqual(t.settings, tile.settings, 'settings');
|
assert.deepEqual(t.settings, tile.settings, 'settings');
|
||||||
@@ -107,7 +108,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
||||||
|
|
||||||
// Saving over the name keeps the copy it replaced.
|
// Saving over the name keeps the copy it replaced.
|
||||||
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
||||||
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
||||||
@@ -125,6 +126,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
r = await api('/api/layouts/Booth%20A');
|
r = await api('/api/layouts/Booth%20A');
|
||||||
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
||||||
|
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
|
||||||
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
||||||
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Minimal Planning Center mock for the Band Lineup module: service types, one
|
||||||
|
// plan today, its team_members (with person+team includes), People birthdates,
|
||||||
|
// and avatar bytes. Enough to exercise the whole board build.
|
||||||
|
const http = require('http');
|
||||||
|
const PORT = Number(process.argv[2] || 24710);
|
||||||
|
const JPEG = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==', 'base64');
|
||||||
|
const TODAY = new Date();
|
||||||
|
const iso = (d) => d.toISOString();
|
||||||
|
// One person's birthday lands in this Wed–Tue week (today is a Wed in the test).
|
||||||
|
const inWeek = new Date(TODAY.getFullYear(), TODAY.getMonth(), TODAY.getDate() + 2);
|
||||||
|
const bdayInWeek = `1994-${String(inWeek.getMonth()+1).padStart(2,'0')}-${String(inWeek.getDate()).padStart(2,'0')}`;
|
||||||
|
|
||||||
|
const PEOPLE = {
|
||||||
|
p1: { name: 'Lemuel Maldonado', pos: 'Vox 1', team: 'Vocals', bd: '1990-03-04' },
|
||||||
|
p2: { name: 'Wes Carter', pos: 'Vox 2', team: 'Vocals', bd: null },
|
||||||
|
p3: { name: 'Jessica Proulx', pos: 'Vox 3', team: 'Vocals', bd: '1992-11-01' },
|
||||||
|
p4: { name: 'Jeremy Cruz', pos: 'Guitar / Lead', team: 'Band', bd: null },
|
||||||
|
p5: { name: 'Alex Gaitan', pos: 'Drums', team: 'Band', bd: bdayInWeek }, // celebrating
|
||||||
|
p6: { name: 'Rosaliz Gomez', pos: 'Piano', team: 'Band', bd: null },
|
||||||
|
p7: { name: 'George Acosta', pos: 'MC', team: 'Production', bd: null },
|
||||||
|
p8: { name: 'Not Shown', pos: 'Camera 2', team: 'Video', bd: null }, // team not allowed → filtered out
|
||||||
|
};
|
||||||
|
const TEAMS = { Vocals: 't1', Band: 't2', Production: 't3', Video: 't4' };
|
||||||
|
|
||||||
|
function send(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'); const p = u.pathname;
|
||||||
|
if (p.startsWith('/avatar/')) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(JPEG); }
|
||||||
|
if (p === '/services/v2/folders') return send(res, { data: [] });
|
||||||
|
if (p === '/services/v2/service_types') return send(res, { data: [{ id: '1', type: 'ServiceType', attributes: { name: 'Sunday Service' }, relationships: { parent: { data: null } } }] });
|
||||||
|
if (/^\/services\/v2\/service_types\/1\/plans$/.test(p)) {
|
||||||
|
if (u.searchParams.get('filter') === 'past') return send(res, { data: [] });
|
||||||
|
return send(res, { data: [{ id: '100', type: 'Plan', attributes: { sort_date: iso(TODAY), title: 'Taking Thoughts Captive - Part 4', dates: 'September 20, 2026' } }] });
|
||||||
|
}
|
||||||
|
if (/^\/services\/v2\/service_types\/1\/plans\/100\/team_members$/.test(p)) {
|
||||||
|
const data = []; const included = [];
|
||||||
|
for (const [id, v] of Object.entries(PEOPLE)) {
|
||||||
|
data.push({ id: `tm-${id}`, type: 'PlanPerson', attributes: { status: 'C', team_position_name: v.pos }, relationships: { person: { data: { type: 'Person', id } }, team: { data: { type: 'Team', id: TEAMS[v.team] } } } });
|
||||||
|
included.push({ id, type: 'Person', attributes: { full_name: v.name, photo_url: `http://127.0.0.1:${PORT}/avatar/${id}` } });
|
||||||
|
}
|
||||||
|
for (const [name, id] of Object.entries(TEAMS)) included.push({ id, type: 'Team', attributes: { name } });
|
||||||
|
return send(res, { data, included, links: {} });
|
||||||
|
}
|
||||||
|
const person = p.match(/^\/people\/v2\/people\/([^/]+)$/);
|
||||||
|
if (person) { const v = PEOPLE[person[1]]; return send(res, { data: { id: person[1], type: 'Person', attributes: { birthdate: v ? v.bd : null } } }); }
|
||||||
|
res.writeHead(404); res.end('{}');
|
||||||
|
});
|
||||||
|
server.listen(PORT, '127.0.0.1', () => console.log(`[pco-band-mock] on http://127.0.0.1:${PORT}`));
|
||||||
Reference in New Issue
Block a user