Compare commits

...
Author SHA1 Message Date
christianandClaude Opus 5.5 79d3237328 Resi Cues 1.0.3: a login without access to the venue stops signing in every poll
Every 401/403 was answered by signing in again on the spot. For a login that
works but may not list the account's venues (media@waters.church at North
Attleboro got 403 on /venues), that was a successful sign-in on every 1s poll
-- the pattern that had just got another Resi account locked.

- A refusal straight after signing in is a permission, not an expired
  session: it is held off like a failed login, with the message "This Resi
  login isn't allowed to see its venues (403) — ask a Resi admin for access".
  Only a session over a minute old is renewed.
- 429 on any request holds off too, not only on the login.
- The players cache-bust fallback no longer drops the parameter for good on a
  401/403/429, which are about the login rather than the parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:27:38 -04:00
christianandClaude Opus 5.5 67f612e371 Resi Cues 1.0.2: a failed Resi login holds off instead of retrying every second
A failed login left no session, so the next 1s poll signed in again, and the
next, for ever. Resi answers that with 429s, and since the retries never let
up the account never got out of them: at North Attleboro the decoder picker
stayed empty with "Resi login failed (429)". The picker made it worse by
building a fresh client -- and signing in -- on every admin page load.

- After a failed login, further attempts wait 15s, doubling to 5min (or as
  long as Resi's Retry-After asks); calls in between fail at once with the
  reason and how long until the next try.
- The status says what happened: rate-limited, password rejected, or Resi
  unreachable.
- The decoder picker reuses the running module's client when the login
  matches, otherwise one client per login, so it shares the hold-off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:14 -04:00
christianandClaude Opus 5.5 0f0adb4c60 Resi Cues 1.0.1: the next cue's name keeps moving on a 30-second Resi
On a Resi that publishes every ~30s, the board could start a few seconds
before a publish and learn a 2s cadence from that partial first gap. Every
honest 30s gap after that was thrown out as a pause, so playback read as
paused 15s into every update: the countdown froze, and the next cue -- and
the name on the Timers card -- stopped moving on.

- The partial gap right after startup is no longer learned, and one outlier
  gap can no longer lock out the real cadence.
- Until a cadence is measured, a pause is only called after 45s, not 15s.
- The Timers card is titled with the cue's name; its status line now says
  "Next cue" / "Held" instead of repeating the name.
- server.test.js awaited nothing, so an async test raced the next one and
  failed on main; tests now run one at a time.

Same fix as the Resi -> ProPresenter bridge and the Companion module 0.1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:59:12 -04:00
christian 8ca88ba10b Merge pull request 'Resi Cues: the countdown to the next cue, on the board and as a timer' (#22) from claude/resi-stage-cues into main 2026-09-24 12:19:49 -04:00
christianandClaude Opus 5 a8d1b8e159 Resi Cues becomes a real module, and Companion keeps the stage timer
Yesterday's version of this was a Python script under `modules/` with a
LaunchAgent to keep it alive — which the guide rules out in as many words:
everything a module does runs inside its server entry, no companion programs
or daemons an operator starts by hand. It also could not put anything on a
dashboard, which is half of what it was wanted for. Both halves are now in
the right place.

`modules/resi-cues/` is an ordinary module. Its tile is one number as large as
the tile allows — the countdown to the next Resi cue, the cue's name above it,
the playhead and the cue just passed underneath, and the whole cue list behind
a tile switch. It also declares `"provides": ["timers"]` and serves `/timers`
and `/timers/stream`, so the countdown and the playhead turn up among the
ProPresenter timers in the Timers module without either side knowing about
the other.

The countdown ticks in the browser. The server sends the playhead with the
instant it was true and whether playback is running, and the tile adds the
elapsed time since; the Timers module extrapolates from `startedAt` the same
way. So a snapshot only has to be sent when something structural changes — a
new cue, a pause, a seek — rather than at 1 Hz to every open dashboard.

`resi.js` holds the part worth reading twice. Resi republishes a decoder's
position only every few seconds, on an irregular cadence, and each reading is
already stale when it appears; a countdown built straight from it sits seconds
behind and lurches. So every reading is kept as media time minus wall time and
the largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip cannot drag backwards. A reading outside the
link's measured noise floor counts as a seek: drop the lock, snap, and re-read
the cue list at once instead of on its timer. Playback is only called paused
once the playhead has been still for longer than Resi's own cadence, because a
fixed timeout mistakes a slow update for a stop and freezes the countdown
mid-service.

Driving the ProPresenter timer on the stage screen is Companion's job now. It
is already a service on that machine, so nothing new has to be kept alive, and
this module no longer has to reach ProPresenter at all — it only has to be
right about Resi, which is the hard part.

`resi.test.js` drives the estimator on a fake clock against nine simulated
Resi profiles — cadences of one to twelve seconds, staleness to three,
irregular timing, variable round trips — covering lock-on, a fired cue, a seek
backwards, a pause, and a pause longer than the offset window. A punctual
fixture proves nothing here: against one, three separate breakages of the
estimator go unnoticed. `server.test.js` runs the real server entry against a
canned Resi account and checks what the two consumers actually read, including
the `/timers` shape field by field. Every mutation tried against either turns
them red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:08:55 -04:00
christianandClaude Opus 5 82c552955d Resi cue countdowns reach the stage screen without Companion
The ask was a Stream Deck button showing the time until the next Resi cue.
What it turned into is simpler: ProPresenter already renders countdowns, and
it already has an HTTP API, so nothing has to sit in between except something
that knows when the next cue is.

`resi_to_propresenter.py` watches a decoder's playhead and keeps a
ProPresenter countdown timer armed with the seconds until its next cue, with
the cue's name as a stage message beside it. Both halves of that come from
Resi's cloud rather than the decoder — the decoder reports its position
upstream and `central.resi.io` hands it back — so there is nothing to install
at the decoder end and nothing polling it every second.

The playhead is estimated rather than repeated. Resi republishes `position`
only every few seconds, irregularly, and each reading is already stale when
it appears; sent straight through, the countdown sat three seconds behind and
lurched. So each reading is kept as media time minus wall time and the
largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip can no longer drag backwards. A reading
outside the link's measured noise floor counts as a seek: drop the lock, snap,
and re-read the cue list immediately instead of on its timer. Playback is
only called paused once the playhead has been still for longer than Resi's own
cadence, because a fixed timeout mistakes a slow update for a stop and freezes
the countdown mid-service.

This is not a tile. There is no `module.json`, so `readManifests` skips the
folder and it never reaches the + picker; it lives under `modules/` because
that is where the production tooling lives. That is also why it carries a
README when no real module does — there is no *Included modules* entry it
could have instead — and the repo README says so where the module list ends.

`install-agent.command` sets up a LaunchAgent so it runs from login and
restarts if it stops. It deliberately runs a copy in Application Support: a
login agent cannot read `~/Documents` without Full Disk Access, and macOS
answers "Operation not permitted" rather than asking. Settings live there too,
outside the checkout, the same way module config does. The Resi password is
read from the Keychain and never written to disk; with none set the agent
says so once and waits rather than relaunching in a loop.

`test_playhead.py` drives the estimator on a fake clock against nine
simulated Resi profiles — update cadences of one to twelve seconds, staleness
to three, irregular timing, variable round trips — and six of seven deliberate
breakages of the estimator turn it red. `test_end_to_end.py` runs the real
loop against a real ProPresenter with a simulated decoder, and has: it armed
for the first cue, counted down in step, rolled over to the second and stopped
when the cues ran out. Neither covers the live Resi calls, which need real
credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 11:35:40 -04:00
christian ea3a920d5e Merge pull request 'Service Times 1.0.0: every campus's services, and what production can't use' (#20) from claude/service-times-module into main 2026-09-24 11:34:38 -04:00
christian f542b34b6c Merge remote-tracking branch 'origin/main' into claude/service-times-module
# Conflicts:
#	README.md
2026-09-24 11:33:48 -04:00
christian a1ea9262c6 Merge pull request 'Band Lineup's docs move to where every other module's live' (#21) from claude/band-lineup-docs-tidy into main 2026-09-24 11:31:03 -04:00
christianandClaude Opus 5 098562c088 Band Lineup's docs move to where every other module's live
Two things were out of place. `modules/band-lineup/README.md` was the only
README inside any of the eight modules — the guide's folder shape is manifest,
client, server, assets, and every other module is described in this README's
*Included modules* instead. It was also describing a module that no longer
exists: photos and birthdays "come from Planning Center", guests "pinned per
slot", and a refresh interval the manifest has never had.

The entry here had drifted the same way, calling the Drive folder somewhere
to put "manual photos" and the board something Planning Center fills. It is
the other way round now, and has been since 1.10: the board is shared server
state and the source of truth, the photo library fills it, and Sync is
auto-fill that only touches slots nobody placed by hand. The entry says that,
and picks up the Choir spot and the title-bar columns while it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 11:30:17 -04:00
christian 408dea7923 Merge pull request 'Band Lineup Display snapshot catches up: 1.3.6 → 1.3.10' (#19) from claude/band-lineup-display-1-3-10 into main 2026-09-24 11:24:56 -04:00
christianandClaude Opus 5 3ca960baf6 Service Times 1.0.0: every campus's services, and what production can't use
Four campuses run different Sunday schedules and take their resi feed from one
of two broadcast origins. When a service goes wrong — a machine crashes, a feed
is unusable — the booth has to know which service at which campus can be
altered, and which campuses are riding on the one that broke. That lived in
people's heads and in messages.

The times come from the church website rather than from configuration, because
the site is what stays current when services change through the year. Each
campus page carries a "Times" block; the module reads it and re-reads it on a
timer. Two things on those pages are not services and are excluded on purpose:
the main-office hours in every page's footer, and the "Office Hours" block
Apollo Beach carries inside its own Times section. The parse is bounded to the
Times heading and stops at either.

A mark on a service — issue, replay, do not use, with a note, and for a resi
campus which of the origin's services it is taking — is the booth's shared
answer, so it lives in the module's server state and reaches every tile over
SSE. Per-tile settings would let two campuses disagree about whether the 9:30 is
going out. Marks are written to the data directory so a restart mid-morning
does not lose them, and they expire after 36 hours so last week's never greet a
new Sunday.

Campuses added by hand in admin are a stopgap for a site that has not caught up
yet: as soon as the website publishes that campus, the site's times take over
and the admin health line says the manual line can be removed.

Verified: the parser against all five live campus pages, and 31 unit tests plus
16 client checks, each proven to fail under mutation — both parse guards, the
merge rule, dependent recording, and three client behaviours. The suite is run
here under JavaScriptCore, since this machine has no Node.

Not verified here: the module guide's resilience checklist needs a running
ProdDash — upstream loss and recovery, two tiles side by side, and tile removal
leaving no timers behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:57:48 -04:00
christianandClaude Opus 5 882a418a0f Band Lineup Display snapshot catches up: 1.3.6 → 1.3.10
The reference fork had drifted four builds behind the app actually running
backstage. What it was missing is Birthday Mode, the feature the ProdDash
module was ported with and this snapshot never had:

- main.js grows the Planning Center birthday fetch (`pcoFetchBirthday`),
  mm-dd normalising, and a `pco-test-birthdays` IPC handler behind the
  settings pane's Test Birthdays button.
- renderer.js gains the whole celebration: the Wednesday→Tuesday birthday
  week, the canvas confetti loop, the gold glow, and the controls that switch
  it on and set its intensity.
- index.html and styles.css carry the Birthday Mode block in settings.

Purely additive — 597 lines in, four out, and those four are the version and
build number. Source only, as the fork has always been: `.claude/` settings
stay as they are (the local copy's are one machine's accumulated permissions,
and it has no launch.json to give), and the stray test file beside them is not
part of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:37:54 -04:00
michael 0e618ea5d3 Merge pull request 'SPL Meter 0.4.1: name a dead input, and stop the spectrogram smearing its labels' (#16) from claude/spl-silent-input into main 2026-09-23 10:03:23 -04:00
Waters ChurchandClaude Opus 5 c8b51c6c2f Spectrogram: stop dragging the labels along with the picture
The spectrogram scrolled by copying the visible canvas onto itself one step
along. That canvas already held the frequency labels and gridlines, because
they were drawn straight onto it, so every frame smeared them sideways and
left a trail. Arrived with the labels in 0.3.0; the gridlines did it too.

Only measured pixels belong on a surface that scrolls. The history now lives
on its own offscreen canvas that holds nothing but spectrum, and the visible
canvas is repainted from scratch each frame — history first, annotations
over the top. Nothing drawn as an annotation can reach the scroll buffer by
construction, rather than by remembering to clear the right strip.

The layout was muddled in the same place and is fixed with it: the gutter
used to be reserved along the bottom while the labels were drawn down the
left, overlapping the plot. Labels now sit beside the axis they describe —
down the left when frequency runs vertically, with room for "12.5k", and
along the bottom when it runs horizontally.

Verified both orientations by fingerprinting the gutter and the plot two and
a half seconds apart: gutter identical, plot changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:58:32 -04:00
Waters ChurchandClaude Opus 5 c464e5b4ca SPL Meter 0.4.0: a dead input must not look like a quiet room
AB's meter appeared to halt for a day and a half. Nothing had halted: the
capture child had run continuously since the machine was restarted, never
stalled, never exited, and was delivering twenty frames a second. Every one
of them was digital silence, because Dante Via stopped passing audio after
the reboot — the Timers module's LTC listener, on channel 16 of the same
device, had been reporting no signal for exactly as long.

The module's part in that was reporting "ok" through all of it and drawing
a steady -83, which is the -200 floor plus the +117 calibration offset. A
plausible number on a wall is believed, and a green dot beside it says the
number is worth believing. That is worse than showing nothing.

So silence is now a state the module names. Below -150 dBFS unweighted the
input is not quiet, it is absent: a microphone in a silent room still reads
about -83, and even a muted converter dithers. Five seconds of that on every
channel and health() goes to error naming the device and how long it has
been dead, the tiles stop drawing a level and say "No audio" with how long,
and the status dot goes red.

Verified both directions, which is the half that matters: BlackHole with
nothing routed into it is caught in five seconds, and a real microphone in a
quiet room at -49 dBFS is never flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:54:27 -04:00
michael f4d50f5e95 Merge pull request 'PCO Plan 1.6.0: the message document is matched by the plan's own name' (#15) from claude/pco-message-match into main 2026-09-22 16:56:49 -04:00
Waters ChurchandClaude Fable 5.1 c73d0a3aac PCO Plan 1.6.0: the message document is matched by the plan's own name
The current item followed the live ProPresenter document by title alone,
and a sermon's document is named after the sermon — Waters names them
"YYMMDD Series Title - Part #" — never after its plan item. So the last
song stayed current through the whole message: North Attleboro on 20 Sept
spent 65 minutes on one song in two of its three services.

A live document that reads like the plan itself — its date code, series
title and part — now lands on the plan's message item: an item titled
Message, Sermon or Teaching, else the first item under such a header. A
date code naming another day is a firm no, so last week's sermon stays
unplaced. Position in the ProPresenter playlist breaks a tie between
equal names, only while the playlist mirrors the plan item for item, and
never places a document on its own.

The log now says what it never said: every feed drop and reconnect, which
item the message document will land on, what placed each item and how,
and a live document that is not in the plan.

matching.test.js: 27 checks built from both campuses' real 27 Sept plans
and ProPresenter playlists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:06:53 -04:00
michael 441cfe3882 Merge pull request 'Grid density: cut the board finer or coarser, per browser' (#14) from claude/grid-size into main 2026-09-22 11:45:51 -04:00
29 changed files with 5093 additions and 148 deletions
+59 -10
View File
@@ -328,7 +328,14 @@ below. Nothing is installed without someone clicking.
description and item notes. When the ProPresenter module is running it
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
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
token), the service type (a list grouped by your Planning Center folders)
and an optional pinned plan. Everything about the display is per tile
@@ -358,15 +365,57 @@ below. Nothing is installed without someone clicking.
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Band Lineup** — the backstage stage board: everyone scheduled on the
band, vocal and production teams for the upcoming Planning Center plan,
placed into their mic slot with photo, name and mic/pack label; birthdays
in the service week get confetti. Guests and MCs not on the plan are pinned
per slot from the title bar's Edit button. Admin config: Planning Center
credentials, service type and plan, the teams to include, the stage slots
(one mic/pack label per line, in stage order), default columns, and a
Google Drive folder for manual photos. Tile settings: names, mic labels,
parallax, and the birthday confetti with its intensity.
- **Resi Cues** — how long until the next Resi cue point, for a campus
running video from another site. The tile is one number, as large as it
will go: the countdown to the next cue, its name above it, and the playhead
and the cue just passed underneath. It also offers that countdown and the
playhead to the **Timers** module, so they can sit among the ProPresenter
timers instead of in a tile of their own.
Both the cue list and the decoder's position come from Resi's cloud rather
than the decoder — the decoder reports upstream and central.resi.io hands it
back — so nothing is installed at the decoder end. Resi republishes a
position only every few seconds and each reading is already stale, so the
playhead is estimated between readings rather than repeated; a countdown
built straight from Resi's number sits seconds behind and moves in lurches.
Admin config: Resi login and password, which decoder to follow (the list is
read from the account), and a folded **Timing** group for the poll
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE.
A photo library fills it: point the module at a Google Drive folder shared
"anyone with the link" (file name → person's name) or at a folder on the
ProdDash machine, and photos added or removed there follow. **Edit** in the
title bar opens that library — drag a photo onto a slot, drag between slots
to swap, rename or clear. Planning Center is optional auto-fill on top, not
the source of truth: **Sync** reads the upcoming plan's team members and
drops each into the slot matching their position, filling only slots nobody
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
raining confetti, and a Choir button adds the extra spot for the weeks there
is one. Admin config: Planning Center credentials, service type and plan, the
teams to include, the stage slots (one mic/pack label per line, in stage
order), default columns, and the photo folder. Tile settings: parallax,
names, mic labels, and birthday confetti with its intensity and a test
picker — columns are − / + in the title bar.
- **Service Times** — every campus's Sunday service times, read from the
church website and re-read on a timer, so the board follows the site when
the times change through the year. Any service can be marked for
production — issue, replay, or do not use — with a note, and a campus
taking resi records which of the origin's services it is playing. Marking
a broadcast service "do not use" flags every campus riding on it, which is
the question the board exists to answer. Marks are shared by every tile
and every browser, survive a restart, and expire on their own so last
week's never greet a new Sunday. The + picker offers the whole board or a
single campus.
Admin config is in four groups: **Website** (address, which campuses to
follow, refresh interval), **Broadcast** (the campuses the broadcast comes
from, and each other campus's resi source), **Manual campuses** (a stopgap
for a campus the site has not published yet — the website takes over as
soon as it does) and **Marks** (how long a mark lives).
Tile settings: campuses, card or list layout, and a **Show** group for the
day, role, resi source, campuses fed, notes and the updated line.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
+28
View File
@@ -57,9 +57,37 @@
<option value="">Choose service type</option>
</select>
<button type="button" class="btn btn-small" id="btn-pco-sync">Sync Upcoming</button>
<button type="button" class="btn btn-small" id="btn-pco-test-birthdays">Test Birthdays</button>
<button type="button" class="btn btn-small" id="btn-pco-disconnect">Disconnect</button>
</div>
<div class="pco-status" id="pco-status">Planning Center is not connected.</div>
<div class="pco-status pco-birthday-result" id="pco-birthday-result"></div>
<div class="bday-block">
<div class="bday-head">
<span class="bday-title">Birthday Mode 🎂</span>
<label class="bday-switch">
<input type="checkbox" id="bday-enabled" />
<span class="bday-switch-label">On</span>
</label>
</div>
<div class="bday-controls" id="bday-controls">
<div class="bday-line">
<span class="bday-line-label">Intensity</span>
<div class="seg-inline" id="bday-intensity" role="group" aria-label="Birthday intensity">
<button type="button" data-int="subtle">Subtle</button>
<button type="button" data-int="full">Full</button>
</div>
</div>
<div class="bday-line">
<span class="bday-line-label">Force on <em>(test)</em></span>
<select id="bday-test" class="pco-select bday-test-select" aria-label="Force birthday on a person for testing">
<option value="">Off</option>
</select>
</div>
<div class="bday-hint">Birthdays celebrate for the whole service week (Wednesday→Tuesday). Force is for testing only — it clears when the app restarts.</div>
</div>
</div>
<div class="pco-position-builder">
<select id="pco-position-option" class="pco-select" aria-label="Planning Center position">
<option value="">Choose position</option>
+167 -1
View File
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
return payload || { data: [] };
}
/**
* Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell
* "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People
* birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do.
*/
async function pcoFetchStatus(target, credentials, searchParams) {
const url = buildPcoUrl(target, searchParams);
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: pcoAuthHeader(credentials),
'User-Agent': pcoUserAgent(),
},
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}
return { status: response.status, ok: response.ok, payload };
}
/**
* Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People
* share the same person id, so the Services person id we already store doubles as the People id.
* Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent.
*/
async function pcoFetchBirthday(personId, credentials) {
if (!personId) return { status: 0, birthday: '', fieldPresent: false };
const { status, ok, payload } = await pcoFetchStatus(
`/people/v2/people/${encodeURIComponent(personId)}`,
credentials,
{ 'fields[Person]': 'birthdate' },
);
if (!ok) return { status, birthday: '', fieldPresent: false };
const attrs = payload?.data?.attributes || {};
return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs };
}
async function pcoFetchAll(target, credentials, searchParams) {
let nextUrl = buildPcoUrl(target, searchParams).toString();
const data = [];
@@ -275,6 +316,15 @@ function emptyState() {
pcoDisplayPositions: [],
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
birthdayMode: { enabled: true, intensity: 'subtle' },
};
}
/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
function normalizeBirthdayMode(entry) {
return {
enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
};
}
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
// "MM-DD" only — we deliberately never persist the birth year (month + day is all
// Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file.
birthday: normalizeBirthdayMmdd(person?.birthday),
};
}
/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */
function normalizeBirthdayMmdd(raw) {
const s = String(raw || '').trim();
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape
if (full) return `${full[1]}-${full[2]}`;
const md = s.match(/^(\d{2})-(\d{2})$/);
if (md) {
const mm = Number(md[1]);
const dd = Number(md[2]);
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`;
}
return '';
}
function normalizeTeamName(value) {
return String(value || '').trim().toLowerCase();
}
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
});
// Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a
// person with no date on file just yields '' and never fails the sync). Fetched once per unique
// person so a member in two positions isn't queried twice.
const birthdayByPerson = new Map();
for (const member of scheduledMembers) {
if (!member.personId || birthdayByPerson.has(member.personId)) continue;
try {
const { birthday } = await pcoFetchBirthday(member.personId, credentials);
birthdayByPerson.set(member.personId, birthday || '');
} catch {
birthdayByPerson.set(member.personId, '');
}
}
const oldPcoFiles = new Set(
(Array.isArray(baseState.library) ? baseState.library : [])
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
source: 'pco',
pcoPersonId: member.personId,
pcoPlanPersonId: member.planPersonId,
birthday: birthdayByPerson.get(member.personId) || '',
});
}
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
const atWriteTime = await loadStateRaw();
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
await saveStateRaw(nextState);
for (const fileName of oldPcoFiles) {
@@ -1222,6 +1305,7 @@ function migrateState(data) {
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
proPresenter: normalizeProPresenterConfig(data.proPresenter),
displayServer: normalizeDisplayServerConfig(data.displayServer),
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
lineup: data.lineup.map((e) => {
if (!e || typeof e !== 'object') return { personId: '', label: '' };
return {
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
// The renderer never edits this (it is changed through the display-* IPC handlers), so
// always carry the stored value forward rather than letting a save blank it out.
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
const birthdayMode = payload?.birthdayMode
? normalizeBirthdayMode(payload.birthdayMode)
: normalizeBirthdayMode(existing.birthdayMode);
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
return true;
});
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
return syncUpcomingPcoPlan(payload);
});
/**
* Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
* People API and read birthdates, and how many synced band members have one on file. Read-only.
*/
ipcMain.handle('pco-test-birthdays', async () => {
const credentials = await loadPcoCredentials();
if (!credentials) return { connected: false };
// 1. Can the token read the People API at all?
let probe;
try {
probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
} catch (err) {
return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
}
if (!probe.ok) {
const detail =
probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
`${probe.status}`;
return { connected: true, canRead: false, status: probe.status, detail };
}
const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
// 2. Coverage across the people we've actually synced (unique by person id).
const state = await loadStateRaw();
const seen = new Set();
const targets = [];
for (const p of Array.isArray(state.library) ? state.library : []) {
if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
seen.add(p.pcoPersonId);
targets.push(p);
}
let withBirthday = 0;
let notFound = 0;
let errors = 0;
const found = [];
for (const p of targets) {
let r;
try {
r = await pcoFetchBirthday(p.pcoPersonId, credentials);
} catch {
errors += 1;
continue;
}
if (r.status === 404) { notFound += 1; continue; }
if (r.status && r.status >= 400) { errors += 1; continue; }
if (r.birthday) {
withBirthday += 1;
found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
}
}
// Soonest upcoming birthday, for a concrete "it works" line.
const today = new Date();
const daysUntil = (mmdd) => {
const [mm, dd] = mmdd.split('-').map(Number);
let next = new Date(today.getFullYear(), mm - 1, dd);
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
return Math.round((next - midnight) / 86400000);
};
const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
return {
connected: true,
canRead: true,
fieldExposed,
total: targets.length,
withBirthday,
notFound,
errors,
found: sortedFound.slice(0, 8),
nextUp,
};
});
ipcMain.handle('pp-status', async () => {
return {
enabled: ppConfig.enabled,
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "band-lineup-display",
"productName": "Band Lineup",
"version": "1.3.6",
"buildNumber": "92",
"version": "1.3.10",
"buildNumber": "96",
"description": "Stage lineup photos with mic/pack labels",
"main": "main.js",
"scripts": {
+1
View File
@@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('lineup', {
pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'),
pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId),
pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload),
pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'),
onMenuAddPhotos: (cb) => {
ipcRenderer.on('menu-add-photos', () => cb());
},
+273 -1
View File
@@ -3,7 +3,7 @@ if (window.__bandLineupRendererLoaded) {
} else {
window.__bandLineupRendererLoaded = true;
/**
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string }} LibraryPerson
* @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson
* @typedef {{ personId: string, label: string }} StageSlot
* @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition
*/
@@ -27,6 +27,10 @@ let pcoDisplayPositions = [];
let pcoPositionOptions = [];
let slotLabelMemory = [];
let slotNameMemory = [];
/** Birthday Mode display settings (persisted). */
let birthdayMode = { enabled: true, intensity: 'subtle' };
/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */
let birthdayTestPersonId = '';
/** @type {StageSlot[]} */
let stageSlots = createEmptySlots();
const imageUrlCache = new Map();
@@ -65,7 +69,13 @@ const btnApplyLayout = document.getElementById('btn-apply-layout');
const btnToggleParallax = document.getElementById('btn-toggle-parallax');
const btnPcoConnect = document.getElementById('btn-pco-connect');
const btnPcoSync = document.getElementById('btn-pco-sync');
const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays');
const btnPcoDisconnect = document.getElementById('btn-pco-disconnect');
const pcoBirthdayResult = document.getElementById('pco-birthday-result');
const bdayEnabled = document.getElementById('bday-enabled');
const bdayIntensity = document.getElementById('bday-intensity');
const bdayControls = document.getElementById('bday-controls');
const bdayTestSelect = document.getElementById('bday-test');
const btnPcoAddPosition = document.getElementById('btn-pco-add-position');
const inputLayoutCols = document.getElementById('layout-cols');
const inputLayoutRows = document.getElementById('layout-rows');
@@ -244,6 +254,7 @@ function setPcoStatus(message, isError = false) {
function setPcoControlsEnabled(enabled) {
btnPcoConnect.disabled = !enabled;
btnPcoSync.disabled = !enabled;
btnPcoTestBirthdays.disabled = !enabled;
btnPcoDisconnect.disabled = !enabled;
btnPcoAddPosition.disabled = !enabled;
pcoServiceTypeSelect.disabled = !enabled;
@@ -427,6 +438,9 @@ function applyLoadedState(data) {
source: person?.source === 'pco' ? 'pco' : 'local',
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
// Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js
// re-validates the "MM-DD" shape on write); Birthday Mode reads this.
birthday: typeof person?.birthday === 'string' ? person.birthday : '',
}))
: [];
const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10);
@@ -451,6 +465,11 @@ function applyLoadedState(data) {
pcoServiceTypeSelect.value = pcoServiceTypeId;
renderPcoDisplayPositions();
stageSlots = normalizeLineup(data.lineup, getStageSlotCount());
birthdayMode = {
enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled),
intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle',
};
syncBirthdayControls();
}
function updateSlotSizeReadout() {
@@ -528,6 +547,7 @@ function scheduleSave() {
slotLabels: slotLabelMemory,
slotNames: slotNameMemory,
lineup: stageSlots,
birthdayMode,
});
}, 250);
}
@@ -950,6 +970,132 @@ function parallaxMotionVars(seedText) {
};
}
/* ---------------- Birthday Mode ---------------- */
const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */
const birthdayCanvases = new Set();
let birthdayRaf = null;
/**
* The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday
* anywhere in this window celebrates all week (so it's already up for the weekend services).
* Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday.
*/
let birthdayWeekCache = { key: '', set: null };
function birthdayWeekSet() {
const now = new Date();
const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`;
if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set;
const set = new Set();
for (let i = 0; i < 7; i += 1) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
birthdayWeekCache = { key, set };
return set;
}
/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */
function isBirthdayActive(person) {
if (!birthdayMode.enabled || !person) return false;
if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true;
return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday);
}
/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */
function birthdayTick() {
const before = birthdayWeekCache.key;
birthdayWeekSet();
if (before && birthdayWeekCache.key !== before) void renderStage();
}
setInterval(birthdayTick, 15 * 60 * 1000);
function birthdayMakeParticle(w, h, intensity, seedY) {
const ribbon = Math.random() < 0.55;
return {
x: Math.random() * w,
y: seedY != null ? seedY : Math.random() * h,
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5,
h: ribbon ? 9 + Math.random() * 8 : 0,
ribbon,
color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0],
rot: Math.random() * Math.PI * 2,
vr: -0.14 + Math.random() * 0.28,
sway: 0.6 + Math.random() * 1.1,
phase: Math.random() * Math.PI * 2,
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
};
}
function birthdaySeedCanvas(c, w, h) {
const base = c._intensity === 'subtle' ? 26 : 64;
const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500)));
c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity));
}
// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the
// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine).
function birthdayStepCanvas(c) {
const w = c.clientWidth;
const h = c.clientHeight;
if (!w || !h) return; // not laid out yet — try again next frame
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (c._w !== w || c._h !== h) {
c.width = w * dpr;
c.height = h * dpr;
c._ctx = c.getContext('2d');
c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
c._w = w;
c._h = h;
birthdaySeedCanvas(c, w, h);
}
const ctx = c._ctx;
ctx.clearRect(0, 0, w, h);
for (const p of c._parts) {
p.phase += 0.03;
p.y += p.vy;
p.x += Math.sin(p.phase) * p.sway;
p.rot += p.vr;
if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16));
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
}
function birthdayLoop() {
for (const c of [...birthdayCanvases]) {
if (!c.isConnected) { birthdayCanvases.delete(c); continue; }
birthdayStepCanvas(c);
}
birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null;
}
function birthdayRegisterCanvas(canvas, intensity) {
canvas._intensity = intensity;
birthdayCanvases.add(canvas);
if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop);
}
/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */
function applyBirthdayEffects(tile, photoArea) {
const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle';
tile.classList.add('birthday', `birthday-${intensity}`);
const confetti = document.createElement('canvas');
confetti.className = 'birthday-confetti';
confetti.setAttribute('aria-hidden', 'true');
photoArea.appendChild(confetti);
birthdayRegisterCanvas(confetti, intensity);
}
function createPhotoTile(person, href, slotIndex, parallaxAssets) {
void parallaxAssets;
const tile = document.createElement('article');
@@ -1002,6 +1148,7 @@ function createPhotoTile(person, href, slotIndex, parallaxAssets) {
photoArea.appendChild(nameBadge);
photoArea.appendChild(removeBtn);
if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea);
tile.appendChild(photoArea);
return tile;
}
@@ -1156,6 +1303,7 @@ async function renderLibrary() {
async function renderAll() {
await renderStage();
await renderLibrary();
renderBirthdayTestOptions();
}
async function importNewPeople(added) {
@@ -1170,6 +1318,7 @@ async function importNewPeople(added) {
source: 'local',
pcoPersonId: '',
pcoPlanPersonId: '',
birthday: '',
});
}
await renderLibrary();
@@ -1232,6 +1381,97 @@ async function syncUpcomingPlanFromPco() {
}
}
const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function formatBirthdayMmdd(mmdd) {
const parts = String(mmdd || '').split('-');
const mm = Number(parts[0]);
const dd = Number(parts[1]);
if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return '';
return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`;
}
function setPcoBirthdayResult(message, tone) {
if (!pcoBirthdayResult) return;
pcoBirthdayResult.textContent = message || '';
pcoBirthdayResult.style.display = message ? '' : 'none';
pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc';
}
async function testPcoBirthdays() {
setPcoControlsEnabled(false);
setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info');
try {
const r = await window.lineup.pcoTestBirthdays();
if (!r || r.connected === false) {
setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error');
return;
}
if (!r.canRead) {
const why = r.reason === 'network'
? `Couldn't reach Planning Center (${r.detail || 'network error'}).`
: `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`;
setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error');
return;
}
// Access works. Report coverage across synced band members.
if (r.total === 0) {
setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good');
return;
}
const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`];
if (r.nextUp) {
const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`;
bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`);
}
if (r.notFound) bits.push(`${r.notFound} not matched in People.`);
setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info');
} catch (error) {
setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error');
} finally {
setPcoControlsEnabled(true);
}
}
/* ---------------- Birthday Mode controls ---------------- */
/** Reflect current birthdayMode settings onto the controls (called after load). */
function syncBirthdayControls() {
if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled;
if (bdayIntensity) {
for (const b of bdayIntensity.querySelectorAll('button')) {
b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false');
}
}
if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45';
}
/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */
function renderBirthdayTestOptions() {
if (!bdayTestSelect) return;
const previous = birthdayTestPersonId;
bdayTestSelect.innerHTML = '<option value="">Off</option>';
const seen = new Set();
stageSlots.forEach((slot, index) => {
if (!slot.personId || seen.has(slot.personId)) return;
const person = getPerson(slot.personId);
if (!person) return;
seen.add(slot.personId);
const option = document.createElement('option');
option.value = person.id;
const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim();
const label = String(slotLabelMemory[index] || '').trim();
option.textContent = label ? `${name} — ${label}` : name;
bdayTestSelect.appendChild(option);
});
// Keep the current override selected if that person is still on stage; otherwise drop it.
if (previous && seen.has(previous)) {
bdayTestSelect.value = previous;
} else if (previous) {
birthdayTestPersonId = '';
bdayTestSelect.value = '';
}
}
/* ---------------- Setup panel tabs ---------------- */
/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */
@@ -1257,6 +1497,7 @@ function activatePanelTab(name) {
}
// The readout measures a stage slot, so it can only be filled in once its pane is laid out.
if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout);
if (name === 'pco') renderBirthdayTestOptions();
}
/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */
@@ -1733,6 +1974,36 @@ btnPcoSync.addEventListener('click', () => {
void syncUpcomingPlanFromPco();
});
btnPcoTestBirthdays.addEventListener('click', () => {
revealChrome();
void testPcoBirthdays();
});
if (bdayEnabled) {
bdayEnabled.addEventListener('change', () => {
birthdayMode.enabled = bdayEnabled.checked;
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayIntensity) {
bdayIntensity.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-int]');
if (!btn) return;
birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle';
syncBirthdayControls();
scheduleSave();
void renderStage();
});
}
if (bdayTestSelect) {
bdayTestSelect.addEventListener('change', () => {
birthdayTestPersonId = bdayTestSelect.value || '';
void renderStage();
});
}
btnPcoDisconnect.addEventListener('click', async () => {
revealChrome();
setPcoControlsEnabled(false);
@@ -1744,6 +2015,7 @@ btnPcoDisconnect.addEventListener('click', async () => {
renderPcoPositionOptions();
scheduleSave();
setPcoStatus('Planning Center disconnected.');
setPcoBirthdayResult('');
} catch (error) {
setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true);
} finally {
+126
View File
@@ -331,6 +331,105 @@ body {
color: #9ea7bc;
}
.pco-birthday-result {
min-height: 0;
line-height: 1.45;
}
.pco-birthday-result:empty {
display: none;
}
/* ---- Birthday Mode settings ---- */
.bday-block {
margin-top: 14px;
border-top: 1px solid #23232c;
padding-top: 12px;
}
.bday-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.bday-title {
font-size: 13px;
font-weight: 700;
color: #e7e7ef;
}
.bday-switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: #9ea7bc;
}
.bday-controls {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bday-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.bday-line-label {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8a8a99;
min-width: 74px;
}
.bday-line-label em {
font-style: normal;
text-transform: none;
letter-spacing: 0;
color: #6d6d7a;
}
.seg-inline {
display: inline-flex;
gap: 4px;
background: #0e0e14;
border: 1px solid #23232c;
border-radius: 9px;
padding: 3px;
}
.seg-inline button {
appearance: none;
border: 0;
background: transparent;
color: #9ea7bc;
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
}
.seg-inline button[aria-pressed="true"] {
background: #f5c451;
color: #241a02;
}
.bday-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #c6cada;
cursor: pointer;
}
.bday-test-select {
flex: 1;
min-width: 150px;
}
.bday-hint {
font-size: 11px;
color: #6d6d7a;
}
.pco-position-builder {
display: none;
grid-template-columns: minmax(0, 1fr) auto;
@@ -624,6 +723,33 @@ body {
font-weight: 500;
}
/* ---- Birthday Mode tile effects ---- */
.tile.birthday-subtle {
animation: bdayGlowSubtle 3.4s ease-in-out infinite;
}
.tile.birthday-full {
animation: bdayGlowFull 2.2s ease-in-out infinite;
}
@keyframes bdayGlowSubtle {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.55), 0 0 14px rgba(245, 196, 81, 0.18); }
50% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.85), 0 0 20px rgba(245, 196, 81, 0.32); }
}
@keyframes bdayGlowFull {
0%, 100% { box-shadow: inset 0 0 0 3px rgba(245, 196, 81, 0.8), 0 0 20px rgba(245, 196, 81, 0.42); }
50% { box-shadow: inset 0 0 0 3px #ffd873, 0 0 34px rgba(245, 196, 81, 0.66); }
}
/* Birthday Mode intentionally keeps animating even under OS "reduced motion" — it's a celebration
the operator opts into, and freezing it leaves confetti stuck mid-fall. */
.birthday-confetti {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.tile .remove {
position: absolute;
top: 6px;
-52
View File
@@ -1,52 +0,0 @@
# Band Lineup module
The backstage stage board as a ProdDash tile: everyone scheduled on the band /
vocal / production teams for the upcoming Planning Center plan, placed into their
mic slot with photo, name and mic/pack label. People whose birthday falls in the
current service week (Wednesday→Tuesday) get raining confetti and a gold glow.
This is the display half of the standalone **Band Lineup** Electron app, ported
to ProdDash. (The ProPresenter Now/Next half of that app is the separate
`propresenter-now-next` module.)
## How it fills the board
1. **Slots** are the mic/pack labels you write in admin, one per line, in the
order they sit on stage — e.g. `[VOX 2] MIX 2`, `[DRUMS]`, `[MC] MC1`. A
blank line is an empty spot.
2. Each refresh the server reads the upcoming plan's **team members** (any team
whose name matches a line in *Teams to include*), and matches each person to
a slot by their Planning Center **position** — the same loose matching the
Electron app uses (`matching.js`; `[VOX 2] MIX 2` takes whoever is on *Vox
2*). A numbered role only matches the same number, and the `MIX n` part is
ignored, so Vox 2 and Vox 3 never swap.
3. **Photos and birthdays** come from Planning Center (birthdays via the People
API; only month + day is kept). Photos are cached on disk and served by the
module; birthdays drive the confetti.
## Manual photos (people not on the plan)
Guest speakers, MCs, stage managers — anyone not scheduled in Planning Center —
are pinned per slot from the tile's **Edit** button (title bar): set a name,
optionally upload a photo, optionally a `MM-DD` birthday. A manual pin overrides
the PCO match for that slot and is kept until you Clear it. Uploaded photos live
in the ProdDash data directory, never in the repo.
## Settings
- **Admin** (server-wide): Planning Center credentials, service type, plan, the
teams to include, the stage slots, columns, refresh interval.
- **Per tile** (gear): birthday confetti on/off + intensity, a force-birthday
test picker, parallax, and show/hide for names and mic labels.
## Testing without Planning Center
```bash
node tools/pco-band-mock.js 24710 # mock PCO
BAND_LINEUP_API_BASE=http://127.0.0.1:24710 node server.js # point the module at it
node modules/band-lineup/matching.test.js # slot-matching unit tests
```
Then in `/admin` under Band Lineup enter any Client ID + Secret, pick "Sunday
Service", and the board fills — one person (on Drums) has a birthday this week,
so their tile rains confetti.
+177 -3
View File
@@ -2,10 +2,12 @@
/**
* 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
* 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
* cues (see service-timing.js).
*
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
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,
};
+280
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.5.0",
"version": "1.6.0",
"proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services",
"provides": [
+69 -34
View File
@@ -33,8 +33,12 @@
* Following ProPresenter: instead of talking to ProPresenter a second time,
* this module reads the propresenter-now-next module's own SSE feed over
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
* matches the live presentation name to a plan item. Item start/end
* timestamps are tracked here, once, so every tile agrees on the runtime.
* places the live document on a plan item (matching.js placeDocument): by
* 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 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 os = require('os');
const path = require('path');
const { MATCH_THRESHOLD, similarity } = require('./matching');
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
const {
MINUTE,
serviceTimes,
@@ -434,6 +438,8 @@ module.exports = {
let inFlight = null;
let lastFrame = '';
let lastTimersSig = '';
let lastMessageItemId = null;
let lastMatchError = '';
/* — broadcasting — */
@@ -582,6 +588,7 @@ module.exports = {
}
state.plan = plan;
state.items = items;
noteMessageItem(items);
state.reachable = true;
state.lastError = '';
state.lastFetched = Date.now();
@@ -596,6 +603,17 @@ module.exports = {
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) — */
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
@@ -996,36 +1014,29 @@ module.exports = {
};
lastLiveName = keep.activeName || '';
if (follow && keep.activeName) {
const hit = matchItem(keep.activeName);
const hit = matchItem(keep.activeName, hintFrom(lastView));
state.live.matched = Boolean(hit);
if (hit) {
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) {
const items = state.items.filter((it) => it.itemType !== 'header');
if (!items.length) return null;
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
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;
if (
!best
|| score > best.score + 0.001
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
) {
best = { item, score, dist };
}
}
return best;
/**
* The plan item a live document stands for — { item, score, via } or
* null. The rules live in matching.js (placeDocument): by name, then the
* message rule for a document named like the plan itself; the playlist
* position only breaks ties, and only while the playlist mirrors the plan.
*/
function matchItem(name, hint) {
return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
}
/** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
function hintFrom(view) {
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
}
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */
@@ -1077,6 +1088,7 @@ module.exports = {
function applyLiveView(view) {
lastView = view && typeof view === 'object' ? view : null;
const live = state.live;
if (!live.available) log('following the ProPresenter module feed');
live.available = true;
live.reason = '';
live.ppEnabled = Boolean(view?.enabled);
@@ -1095,23 +1107,40 @@ module.exports = {
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
return;
}
lastLiveName = name;
rememberDocument(name);
if (follow) {
const hit = matchItem(name);
live.matched = Boolean(hit);
if (hit) {
live.currentScore = hit.score;
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
try {
const hit = matchItem(name, hintFrom(view));
live.matched = Boolean(hit);
if (hit) {
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.
syncServiceSafe(Date.now(), name);
broadcast();
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 now = Date.now();
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
@@ -1120,12 +1149,18 @@ module.exports = {
live.currentItemId = itemId;
live.history[itemId] = { startedAt: now, endedAt: 0 };
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) {
const live = state.live;
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.reason = reason;
live.ppReachable = false;
+138
View File
@@ -0,0 +1,138 @@
/* Resi Cues — the tile.
*
* One number matters: how long until the next cue. It is drawn as large as the
* tile allows, with the cue's name above it, because on a wall the operator reads
* it from across the booth.
*
* The countdown ticks locally. The server sends the playhead with the instant it
* was true (`estMs` at `estAt`) and whether playback is running; this adds the
* elapsed time since. That is what keeps the digits moving every second while
* Resi itself only reports every few seconds, and it means a slow poll never
* shows as a stalled clock.
*/
const TICK_MS = 250; // redraw faster than a second so the digit turns on time
export default function create({ root, moduleApi }) {
let stream = null;
let ticker = null;
let state = null;
const settings = () => moduleApi.instanceSettings;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function hms(ms) {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Where the playhead is right now, extrapolated from the last server word. */
function playheadMs() {
if (!state || state.estMs === null || state.estMs === undefined) return null;
return state.estMs + (state.playing ? Date.now() - state.estAt : 0);
}
function render() {
root.textContent = '';
const set = settings();
if (!state || state.status === 'error') {
const box = el('div', 'resi-empty');
box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected'));
root.append(box);
return;
}
const here = playheadMs();
const wrap = el('div', 'resi-wrap');
if (state.next) {
if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT'));
if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue'));
const remaining = here === null ? null : state.next.ms - here;
const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining));
if (remaining !== null && remaining <= 30000) count.classList.add('is-soon');
if (!state.playing) count.classList.add('is-held');
wrap.append(count);
} else {
wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING'));
wrap.append(el('div', 'resi-cue', state.event || state.decoder || ''));
}
const foot = el('div', 'resi-foot');
if (set.showPrevious !== false && state.prev) {
foot.append(el('span', 'resi-foot-item',
`after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`));
}
if (set.showPosition !== false && here !== null) {
foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`));
}
if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused'));
if (foot.childElementCount) wrap.append(foot);
if (set.showCueList && state.cues && state.cues.length) {
const list = el('ul', 'resi-list');
for (const cue of state.cues) {
const row = el('li', 'resi-list-row');
if (here !== null && cue.ms <= here) row.classList.add('is-past');
if (state.next && cue.ms === state.next.ms) row.classList.add('is-next');
row.append(el('span', 'resi-list-name', cue.name));
row.append(el('span', 'resi-list-at', hms(cue.ms)));
list.append(row);
}
wrap.append(list);
}
root.append(wrap);
}
function applyStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server');
if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected');
if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`);
const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : '';
return moduleApi.setStatus('ok', `${state.decoder}${cadence}`);
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting');
stream = moduleApi.sse('/stream', {
state: (data) => {
try {
state = JSON.parse(data);
} catch {
return;
}
applyStatus();
render();
},
error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'),
});
ticker = setInterval(render, TICK_MS);
render();
},
stop() {
if (stream) stream.close();
if (ticker) clearInterval(ticker);
stream = null;
ticker = null;
root.textContent = '';
},
onConfigChange() {
render();
},
};
}
+91
View File
@@ -0,0 +1,91 @@
{
"id": "resi-cues",
"name": "Resi Cues",
"version": "1.0.3",
"proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 3 },
"configGroups": {
"Resi account": {
"help": "The Resi Studio login the board reads with. Cue points and the decoder's playhead both come from Resi's cloud, so nothing is installed at the decoder. Worth using a dedicated low-privilege Resi user rather than a personal one."
},
"Decoder": {
"help": "Which decoder to follow. The list is read from the account once the login above is saved."
},
"Timing": {
"collapsed": true,
"help": "Resi refreshes a decoder's position only every few seconds, on an irregular cadence, and each reading is already slightly stale. The board estimates between readings rather than repeating them, so these only change how often it asks — not how accurate it is."
}
},
"configSchema": {
"username": {
"type": "string",
"label": "Resi login",
"group": "Resi account",
"default": "",
"help": "The email address used to sign in to studio.resi.io."
},
"password": {
"type": "password",
"label": "Password",
"group": "Resi account",
"default": ""
},
"player": {
"type": "select",
"label": "Decoder",
"group": "Decoder",
"default": "",
"optionsRoute": "/decoders",
"help": "Leave unset to follow the only decoder on the account."
},
"pollSeconds": {
"type": "number",
"label": "Position poll (s)",
"group": "Timing",
"default": 1,
"help": "How often Resi is asked where the playhead is. A faster poll notices a fired cue sooner; it does not make the countdown smoother, which is handled by estimating between readings."
},
"cueRefreshSeconds": {
"type": "number",
"label": "Cue list refresh (s)",
"group": "Timing",
"default": 5,
"help": "How often the cue list is re-read. It is also re-read immediately whenever the playhead jumps, so a cue fired at the host shows up without waiting for this."
},
"positionTrimMs": {
"type": "number",
"label": "Position trim (ms)",
"group": "Timing",
"default": 0,
"help": "Leave at zero. Only needed if the whole chain reads consistently behind; a positive value pushes the estimate forward."
}
},
"instanceSchema": {
"showCueName": {
"type": "switch",
"label": "Cue name",
"default": true
},
"showPrevious": {
"type": "switch",
"label": "Previous cue",
"default": true
},
"showPosition": {
"type": "switch",
"label": "Playhead",
"default": true
},
"showCueList": {
"type": "switch",
"label": "Cue list",
"default": false
}
}
}
+338
View File
@@ -0,0 +1,338 @@
/* Resi Cues — talking to Resi, and working out where the decoder actually is.
*
* Two things live here, both free of the shell so they can be tested on their own
* (`resi.test.js`): a thin client for the Resi Central API, and the playhead
* estimator that turns its coarse, late readings into a usable position.
*
* The API is the undocumented one behind studio.resi.io. It is not supported by
* Resi and can change without notice; that is the price of there being no public
* way to read cue points at all.
*/
'use strict';
const RESI = 'https://central.resi.io';
/* ── time ──────────────────────────────────────────────────────────────── */
/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */
function positionToMs(position) {
if (typeof position !== 'string') return null;
const parts = position.split(':');
if (parts.length !== 3) return null;
const hh = Number(parts[0]);
const mm = Number(parts[1]);
const ss = Number(parts[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null;
return Math.round((hh * 3600 + mm * 60 + ss) * 1000);
}
/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */
function msToPosition(total) {
const t = Math.max(0, Math.floor(total));
const pad = (n, w) => String(n).padStart(w, '0');
return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}`
+ `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`;
}
/* ── the playhead estimator ────────────────────────────────────────────── */
const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
/**
* Resi republishes a decoder's `position` only every few seconds, on an irregular
* cadence, and each reading is already a little stale when it appears. Shown raw,
* a countdown built on it sits seconds behind and moves in lurches.
*
* So every reading is kept as *media time minus wall time*. While playback runs at
* 1x that difference is constant, and the LARGEST recent value is the reading that
* reached us with the least delay — the one to trust. The estimate is then simply
* "now plus that offset", which advances smoothly by itself and cannot be dragged
* backwards by a slow round trip.
*
* `now` is injected so the tests can drive a fake clock.
*/
function createPlayhead(options) {
const opts = options || {};
const trimMs = Number(opts.trimMs) || 0;
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving
let changes = 0; // readings that differed from the one before
let rejected = false; // the last gap looked like a pause and was skipped
let base; // the trusted offset
let lastMedia;
let lastNewAt;
let lastEst;
let seeked = false;
function note(mediaMs) {
if (!Number.isFinite(mediaMs)) return;
const t = now();
if (mediaMs !== lastMedia) {
const off = mediaMs - t;
// Anything inside the measured noise floor is noise; only a move clearly
// beyond it is a real seek. Measured rather than assumed, and remembered
// across a reset so the first reading after one is not judged blind.
const noise = spreads.length ? Math.max.apply(null, spreads) : 0;
const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000);
if (base !== undefined) {
const delta = off - base;
if (Math.abs(delta) > threshold) {
offsets = []; // the window describes where we used to be
seeked = true;
// Only abandon the no-going-backwards guard for a move big enough that
// snapping is obviously right; a marginal call must not make the
// displayed playhead lurch backwards.
if (Math.abs(delta) > 10000) lastEst = undefined;
} else if (delta < 0) {
spreads = spreads.concat(-delta).slice(-20);
}
}
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
// from it to the next one is only a fraction of the real cadence. Learning
// from it locked in a 2s cadence that rejected every honest 30s gap.
if (lastNewAt !== undefined && changes > 0) {
const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice.
// But a pause is one gap; twice in a row is the cadence having changed.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000) {
if (!worst || gap <= worst * 3 || rejected) {
gaps = gaps.concat(gap).slice(-8);
rejected = false;
} else {
rejected = true;
}
}
}
if (lastNewAt !== undefined) changes++;
offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs;
lastNewAt = t;
}
const kept = offsets.filter((o) => t - o.at <= WINDOW_MS);
// Always keep the newest reading even once it ages out. If the window empties
// during a long pause there is nothing left to compare against, and the resume
// reads as an ordinary reading instead of the discontinuity it is.
offsets = kept.length ? kept : offsets.slice(-1);
base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined;
}
function read() {
if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 };
const t = now();
const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0;
// Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two.
// Until a full gap has been measured the cadence is unknown; assume the slowest
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
}
let est = t + base + trimMs;
if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max
lastEst = est;
return { estMs: est, playing: true, cadenceMs };
}
/** True once per detected discontinuity — the caller re-reads the cue list. */
function takeSeek() {
const was = seeked;
seeked = false;
return was;
}
return { note, read, takeSeek };
}
/* ── the Resi Central API ──────────────────────────────────────────────── */
/**
* Login sets a `livingas1` cookie which doubles as the bearer token, sent as
* `Authorization: X-Bearer <cookie>`. Everything else is a plain GET.
*/
function createResiClient(options) {
const opts = options || {};
const doFetch = opts.fetch || globalThis.fetch;
let cookieHeader = '';
let token = '';
let loggedInAt = 0;
let customerId = '';
let cacheBust = true;
function headers() {
const h = { Accept: 'application/json' };
if (cookieHeader) h.Cookie = cookieHeader;
if (token) h.Authorization = `X-Bearer ${token}`;
return h;
}
/* A failed login used to be retried on the very next poll -- once a second, for
ever. Resi answers that with 429s, and since the retries never let up, the
account never got out of them. So a failure now holds off further attempts,
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
call in between fails at once with the same reason instead of signing in. */
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let loginFailures = 0;
let loginBlockedUntil = 0;
let loginError = '';
async function login() {
if (now() < loginBlockedUntil) {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res;
try {
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }),
});
} catch (err) {
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
throw new Error(loginError);
}
if (!res.ok) {
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
: `Resi login failed (${res.status})`;
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff(reason, retryAfter * 1000);
throw new Error(reason);
}
loginFailures = 0;
loginBlockedUntil = 0;
loginError = '';
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {};
for (const raw of setCookies) {
const first = String(raw).split(';')[0];
const eq = first.indexOf('=');
if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1);
}
token = jar.livingas1 || '';
if (!token) throw new Error('Resi login returned no session cookie');
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
loggedInAt = now();
const me = await get('/api_v2.svc/users/me');
customerId = (me && me.customerId) || '';
if (!customerId) throw new Error('This Resi account has no customerId');
return me;
}
function holdOff(reason, askedMs) {
loginFailures++;
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
loginError = reason;
}
async function ensureSession() {
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
}
/* The same hold-off covers every request, not just the login. A 401/403 used to
sign in again on the spot, every time: for a login that simply isn't allowed to
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
after signing in is taken as what it is -- a permission -- and held off like a
failed login; only an older session is renewed. A 429 holds off too. */
async function get(path) {
if (now() < loginBlockedUntil) {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
await login();
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
}
if (res.status === 401 || res.status === 403) {
const where = path.includes('/venues') ? 'its venues'
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
throw new Error(loginError);
}
if (res.status === 429) {
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
throw new Error(loginError);
}
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json();
}
async function venues() {
await ensureSession();
return (await get(`/api/v3/customers/${customerId}/venues`)) || [];
}
/** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh
* one — and if the service objects to the parameter, stop sending it. */
async function players(venueUuid) {
await ensureSession();
const path = `/api_v2.svc/users/${venueUuid}/players`;
if (cacheBust) {
try {
return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) {
// Only an objection to the parameter itself; a refusal or a "slow down"
// (401/403/429) is about the login, and dropping it then lost fresh readings.
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
if (!code || ['401', '403', '429'].includes(code)) throw err;
cacheBust = false;
}
}
return (await get(path)) || [];
}
async function event(eventId) {
await ensureSession();
return get(`/api/v3/customers/${customerId}/events/${eventId}`);
}
async function cues(profileId, eventId) {
await ensureSession();
const raw = (await get(
`/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || [];
return raw
.map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) }))
.filter((c) => c.ms !== null)
.sort((a, b) => a.ms - b.ms);
}
return {
login,
ensureSession,
venues,
players,
event,
cues,
get customerId() { return customerId; },
};
}
/** The cue after `estMs`, and the one before it. */
function surroundingCues(cues, estMs) {
let prev = null;
let next = null;
for (const c of cues || []) {
if (c.ms > estMs) { next = c; break; }
prev = c;
}
return { prev, next };
}
module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI };
+236
View File
@@ -0,0 +1,236 @@
'use strict';
/**
* Unit tests for the Resi playhead estimator.
*
* node modules/resi-cues/resi.test.js
* node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile
*
* Plain Node, no dependencies; exits non-zero on the first failure.
*
* The estimator's whole job is to be right about a feed that is coarse, late and
* irregular, so the fixture is a simulated Resi rather than a list of values: a
* decoder playing at 1x, and an API that republishes its position every so often,
* already stale, over a round trip that varies. Nine profiles span what the real
* one has been seen to do. Feeding it a kind, punctual API proves nothing —
* against one, three separate breakages of this code go unnoticed.
*/
const assert = require('assert');
const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi');
let passed = 0;
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
/* ── the simulated world ───────────────────────────────────────────────── */
function makeWorld({ cadence, lag, jitter, net }) {
let seed = 12345;
const rnd = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const w = {
t: 1700000000000,
media: 8 * 60000 + 48000,
playing: true,
published: 0,
lastPublish: 0,
nextCadence: cadence,
};
w.published = w.media - lag;
w.lastPublish = w.t;
w.advance = (ms) => {
for (let i = 0; i < ms; i += 100) {
w.t += 100;
if (w.playing) w.media += 100;
if (w.t - w.lastPublish >= w.nextCadence) {
w.lastPublish = w.t;
w.published = w.media - lag;
w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter));
}
}
};
// A poll reads what Resi had when the request left, and the round trip costs time.
w.poll = () => {
const body = w.published;
if (net) w.advance(100 + Math.round(rnd() * net));
return body;
};
return w;
}
const CUES = [
{ name: 'Welcome', ms: 5 * 60000 },
{ name: 'Campus Host', ms: 12 * 60000 + 30000 },
{ name: 'Message', ms: 41 * 60000 },
{ name: 'Closing', ms: 45 * 60000 },
];
function runProfile(cadence, lag, jitter, net) {
const w = makeWorld({ cadence, lag, jitter, net });
const head = createPlayhead({ now: () => w.t });
const tol = lag + 1200 + jitter + net * 0.35;
const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`;
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; };
test(`${label}: locks on, and beats the raw reading it is built from`, () => {
seconds(40);
let worstEst = 0;
let worstRaw = 0;
for (let i = 0; i < 30; i++) {
const { estMs } = second();
worstEst = Math.max(worstEst, w.media - estMs);
worstRaw = Math.max(worstRaw, w.media - w.published);
}
assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`);
if (cadence >= 2000) {
assert.ok(worstRaw >= cadence * 0.4,
`fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`);
}
assert.ok(head.read().playing, 'should read as playing');
});
test(`${label}: ticks every second and never runs backwards`, () => {
let prev = head.read().estMs;
let stalls = 0;
let backwards = 0;
for (let i = 0; i < 200; i++) {
const { estMs } = second();
if (estMs === prev) stalls++;
if (estMs < prev) backwards++;
prev = estMs;
}
assert.strictEqual(stalls, 0, 'the playhead stalled');
assert.strictEqual(backwards, 0, 'the playhead went backwards');
});
test(`${label}: follows a fired cue, and rolls over the one it passes`, () => {
w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
let reported = false;
for (let i = 0; i < 60 && !reported; i++) {
second();
reported = Math.abs((head.read().estMs || 0) - w.media) < 8000;
}
assert.ok(reported, 'the jump was never picked up');
assert.ok(head.takeSeek(), 'the jump was not flagged as a seek');
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2,
`resynced ${Math.round(w.media - estMs)}ms off`);
assert.ok(w.media - estMs >= -400, 'overshot the new playhead');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message');
const after = seconds(15);
assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing');
assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message');
});
test(`${label}: follows a seek backwards`, () => {
w.media = 6 * 60000;
for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second();
const { estMs } = head.read();
assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back');
assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host');
});
test(`${label}: freezes on a real pause and re-locks on resume`, () => {
w.playing = false;
const stoppedAt = w.media;
const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000))
+ cadence + jitter + 6000) / 1000);
let r = seconds(wait);
assert.strictEqual(r.playing, false, 'did not notice the pause');
const held = r.estMs;
r = seconds(20);
assert.strictEqual(r.estMs, held, 'kept counting while paused');
w.playing = true;
r = seconds(25);
assert.strictEqual(r.playing, true, 'did not notice the resume');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000,
'the frozen position crept instead of stopping');
});
test(`${label}: survives a pause longer than the offset window`, () => {
w.playing = false;
let r = seconds(150);
assert.strictEqual(r.playing, false, 'should still read as paused');
assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely');
w.playing = true;
head.takeSeek();
r = seconds(30);
assert.strictEqual(r.playing, true, 'did not recover');
assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place');
assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek');
});
}
/* ── the plain bits ────────────────────────────────────────────────────── */
test('positionToMs reads Resi positions, and rejects anything else', () => {
assert.strictEqual(positionToMs('00:00:00.000'), 0);
assert.strictEqual(positionToMs('01:02:03.500'), 3723500);
assert.strictEqual(positionToMs('00:00:01'), 1000);
assert.strictEqual(positionToMs(''), null);
assert.strictEqual(positionToMs('garbage'), null);
assert.strictEqual(positionToMs(null), null);
assert.strictEqual(positionToMs(undefined), null);
});
test('msToPosition round-trips', () => {
for (const ms of [0, 1500, 3723500, 36000000]) {
assert.strictEqual(positionToMs(msToPosition(ms)), ms);
}
});
test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message');
assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host');
assert.strictEqual(surroundingCues(CUES, 0).prev, null);
assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null);
assert.strictEqual(surroundingCues([], 1000).next, null);
});
/* ── starting partway through Resi's cycle ─────────────────────────────── */
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
// and the next cue (and its name) stopped moving on.
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
w.lastPublish = w.t - into;
const head = createPlayhead({ now: () => w.t });
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
let paused = 0;
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
if (burst) {
w.media += 15 * 60000; // a seek, published at once...
w.lastPublish = w.t - w.nextCadence;
second(); second();
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
}
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
});
}
/* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number);
const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [
[5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800],
[10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000],
[12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000],
];
for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net);
console.log(`\n${passed} tests passed`);
+372
View File
@@ -0,0 +1,372 @@
/* Resi Cues — server entry.
*
* Follows one Resi decoder and works out how long until its next cue point, then
* offers that two ways:
*
* GET /state → snapshot for this module's tile
* GET /stream → SSE, `state` events on every change
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
* GET /decoders → optionsRoute for the admin page's decoder picker
*
* Both the cue list and the playhead come from Resi's cloud rather than the
* decoder: the decoder reports its position upstream and central.resi.io hands it
* back. Nothing is installed at the decoder end, and nothing here talks to the
* decoder directly.
*
* Why the playhead is estimated rather than repeated is explained in resi.js.
*/
'use strict';
const { createResiClient, createPlayhead, surroundingCues, positionToMs } = require('./resi');
const HEARTBEAT_MS = 15000;
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text);
}
function sseHead(res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
}
function writeAll(set, chunk) {
for (const res of set) {
try {
res.write(chunk);
} catch {
set.delete(res);
}
}
}
/** What the tile and the Timers module see before anything has been configured. */
function emptyState(message) {
return {
status: 'error',
message: message || 'Not configured',
decoder: '',
event: '',
playing: false,
estMs: null,
estAt: Date.now(),
cadenceMs: 0,
next: null,
prev: null,
cues: [],
};
}
function emptyTimers(now) {
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers: [] };
}
let current = null; // the live handle, shared with routes()
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
module.exports = {
init({ config, log }) {
const username = String(config.username || '').trim();
const password = String(config.password || '');
const wanted = String(config.player || '').trim();
const pollMs = Math.max(250, Number(config.pollSeconds || 1) * 1000);
const cueRefreshMs = Math.max(1000, Number(config.cueRefreshSeconds || 5) * 1000);
const stateStreams = new Set();
const timerStreams = new Set();
let state = emptyState(username && password ? 'Connecting' : 'Enter the Resi login in admin');
let timer = null;
let heartbeat = null;
let stopped = false;
const resi = createResiClient({ username, password });
const head = createPlayhead({ trimMs: Number(config.positionTrimMs || 0) });
let venueUuid = '';
let hardwareId = '';
let decoderName = '';
let eventId = '';
let profileId = '';
let eventName = '';
let cues = [];
let lastCueLoad = 0;
let inFlight = false;
/* — what the two consumers see — */
function publishState(next) {
const before = JSON.stringify(state);
state = next;
if (JSON.stringify(state) === before) return;
writeAll(stateStreams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
/**
* The Timers module extrapolates from `startedAt` and its own clock, so this
* only has to change when a timer changes *structurally* — a new cue, a pause,
* a seek. Broadcasting every poll would make every dashboard repaint at 1 Hz
* for nothing.
*/
function timersSignature(snap) {
return JSON.stringify(snap.timers.map(
(t) => [t.id, t.label, t.state, t.startedAt, t.targetMs, t.status && t.status.text]));
}
let lastTimerSig = '';
function publishTimers() {
const snap = timersSnapshot(Date.now());
const sig = timersSignature(snap);
if (sig === lastTimerSig) return;
lastTimerSig = sig;
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot(now) {
const timers = [];
if (state.estMs !== null && state.estMs !== undefined) {
const est = state.estMs + (state.playing ? now - state.estAt : 0);
if (state.next) {
const remainingMs = Math.max(0, state.next.ms - est);
// The countdown "runs" from the cue before it, which is what gives the
// consumer something to colour progress against.
const from = state.prev ? state.prev.ms : 0;
timers.push({
id: 'next-cue',
label: state.next.name || 'Next cue',
kind: 'countdown',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est - from),
elapsedMs: null,
remainingMs,
targetMs: Math.max(1000, state.next.ms - from),
// The label already is the cue's name, and it is what the card is titled
// with; repeating it on the status line only crowded the card.
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '',
});
}
timers.push({
id: 'playhead',
label: 'Playhead',
kind: 'elapsed',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est),
elapsedMs: Math.max(0, est),
remainingMs: null,
targetMs: null,
status: { text: state.decoder || '', tone: state.playing ? 'ok' : 'muted' },
detail: state.prev ? `After ${state.prev.name}` : '',
});
}
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers };
}
/* — finding the decoder — */
async function locate() {
const venues = await resi.venues();
for (const v of venues) {
const players = await resi.players(v.uuid);
for (const p of players) {
if (wanted ? p.playerHardwareId === wanted : true) {
venueUuid = v.uuid;
hardwareId = p.playerHardwareId;
decoderName = `${v.userName || v.name || 'Venue'} — ${p.userName || hardwareId}`;
return true;
}
}
}
return false;
}
/* — the loop — */
async function tick() {
if (inFlight || stopped) return;
inFlight = true;
try {
if (!hardwareId && !(await locate())) {
publishState(emptyState(wanted ? 'That decoder is not on this account' : 'No decoders found'));
return;
}
const players = await resi.players(venueUuid);
const me = players.find((p) => p.playerHardwareId === hardwareId);
if (!me) {
publishState({ ...emptyState('Decoder not listed'), decoder: decoderName });
return;
}
head.note(positionToMs(me.position));
if (me.eventId && me.eventId !== eventId) {
eventId = me.eventId;
const ev = (await resi.event(eventId)) || {};
profileId = ev.eventProfileId || '';
eventName = ev.name || '';
cues = profileId ? await resi.cues(profileId, eventId) : [];
lastCueLoad = Date.now();
log(`event ${eventName || eventId} — ${cues.length} cue(s)`);
} else if (!me.eventId) {
eventId = '';
profileId = '';
eventName = '';
cues = [];
}
const seeked = head.takeSeek();
if (profileId && eventId && (seeked || Date.now() - lastCueLoad >= cueRefreshMs)) {
lastCueLoad = Date.now();
const fresh = await resi.cues(profileId, eventId);
if (JSON.stringify(fresh) !== JSON.stringify(cues)) {
log(`cue list changed — ${fresh.length} cue(s)`);
cues = fresh;
}
}
const { estMs, playing, cadenceMs } = head.read();
const { prev, next } = surroundingCues(cues, estMs === null ? -1 : estMs);
publishState({
status: 'ok',
message: '',
decoder: decoderName,
event: eventName,
playing,
estMs,
estAt: Date.now(),
cadenceMs,
next,
prev,
cues,
});
} catch (err) {
const message = (err && err.message) || String(err);
log(`poll failed: ${message}`);
publishState({ ...state, status: 'error', message });
} finally {
inFlight = false;
publishTimers();
}
}
if (username && password) {
timer = setInterval(() => { void tick(); }, pollMs);
void tick();
} else {
log('no Resi login configured — idle');
}
heartbeat = setInterval(() => {
writeAll(stateStreams, ': ping\n\n');
writeAll(timerStreams, ': ping\n\n');
}, HEARTBEAT_MS);
current = {
get state() { return state; },
timersSnapshot,
stateStreams,
timerStreams,
resi,
credentialsKey: `${username}\n${password}`,
configured: Boolean(username && password),
stop() {
stopped = true;
if (timer) clearInterval(timer);
if (heartbeat) clearInterval(heartbeat);
for (const res of [...stateStreams, ...timerStreams]) {
try { res.end(); } catch { /* already gone */ }
}
stateStreams.clear();
timerStreams.clear();
if (current && current.stop === this.stop) current = null;
},
health() {
if (!username || !password) return { status: 'error', message: 'Enter the Resi login in admin' };
if (state.status === 'ok') {
return { status: 'ok', message: state.playing ? `Playing — ${state.decoder}` : `Paused — ${state.decoder}` };
}
if (state.status === 'error') return { status: 'error', message: state.message };
return { status: 'connecting', message: state.message };
},
};
return current;
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, current ? current.state : emptyState('Module restarting'));
},
'GET /stream': (req, res) => {
sseHead(res);
const snap = current ? current.state : emptyState('Module restarting');
res.write(`event: state\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.stateStreams.add(res);
req.on('close', () => current && current.stateStreams.delete(res));
},
'GET /timers': (req, res) => {
const now = Date.now();
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
},
'GET /timers/stream': (req, res) => {
sseHead(res);
const now = Date.now();
const snap = current ? current.timersSnapshot(now) : emptyTimers(now);
res.write(`event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.timerStreams.add(res);
req.on('close', () => current && current.timerStreams.delete(res));
},
// The admin page's decoder picker. A live call, because it is how the decoder
// gets chosen in the first place — there is nothing cached to answer from.
'GET /decoders': async (req, res) => {
if (!config.username || !config.password) {
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
}
try {
// The running module's client when it has these credentials, else one kept
// per login: a fresh client per request signed in on every admin page load,
// and its own hold-off never applied -- which kept Resi rate-limiting.
const username = String(config.username).trim();
const key = `${username}\n${config.password}`;
let client;
if (current && current.resi && current.credentialsKey === key) client = current.resi;
else {
if (!pickerClient || pickerClient.key !== key) {
pickerClient = { key, client: createResiClient({
username, password: String(config.password),
}) };
}
client = pickerClient.client;
}
const options = [];
for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) {
options.push({
value: p.playerHardwareId,
label: `${p.userName || p.playerHardwareId}`,
group: v.userName || v.name || 'Venue',
});
}
}
json(res, 200, { options });
} catch (err) {
log(`decoder list failed: ${(err && err.message) || err}`);
json(res, 502, { options: [], error: (err && err.message) || String(err) });
}
},
};
},
};
+280
View File
@@ -0,0 +1,280 @@
'use strict';
/**
* Server-entry tests for Resi Cues.
*
* node modules/resi-cues/server.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Resi is
* replaced with a canned account — a venue, a decoder on an event, four cue
* points — so this covers the parts `resi.test.js` cannot: signing in, finding
* the decoder, following the event, and the two snapshots other things read
* (`/state` for the tile, `/timers` for the Timers module).
*
* Nothing here reaches the network, and the poll interval is captured rather
* than run, so the test drives the loop a tick at a time.
*/
const assert = require('assert');
let passed = 0;
// Awaited, so an async test finishes before the next one changes the world under it.
async function test(name, fn) {
await fn();
passed++;
console.log(` ok ${name}`);
}
/* ── a canned Resi account ─────────────────────────────────────────────── */
const CUES = [
{ name: 'Welcome', position: '00:05:00.000' },
{ name: 'Campus Host', position: '00:12:30.000' },
{ name: 'Message', position: '00:41:00.000' },
];
const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 };
function reply(body, extra) {
return Object.assign({
ok: true,
status: 200,
headers: { get: () => null, getSetCookie: () => [] },
json: async () => body,
}, extra || {});
}
globalThis.fetch = async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
world.logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]);
if (path === '/api_v2.svc/users/V1/players') {
world.playerCalls++;
return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]);
}
if (path === '/api/v3/customers/CUST/events/EV1') {
return reply({ name: 'Sunday 9am', eventProfileId: 'P1' });
}
if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES);
return reply({}, { ok: false, status: 404 });
};
/* ── the shell, as much of it as the module touches ────────────────────── */
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
let tickFn = null;
globalThis.setInterval = (fn, ms) => {
if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one
return { fn, ms };
};
globalThis.clearInterval = () => {};
const server = require('./server');
const logged = [];
const handle = server.init({
config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 },
log: (m) => logged.push(m),
});
/** Let the poll's promise chain finish; nothing here waits on real time. */
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function collect(route, req) {
const res = {
code: 0, headers: null, body: '',
writeHead(code, headers) { this.code = code; this.headers = headers; },
write(chunk) { this.body += chunk; },
end(chunk) { if (chunk) this.body += chunk; },
};
route(req || { on() {} }, res);
return res;
}
(async () => {
await settle();
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
await test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message);
assert.strictEqual(state.decoder, 'Main — Lobby');
assert.strictEqual(state.event, 'Sunday 9am');
assert.strictEqual(state.cues.length, 3);
});
await test('works out the cue either side of the playhead', () => {
const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
});
await test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
});
await test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
assert.ok(res.body.includes('retry: 3000'), 'should set a retry');
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
});
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi');
assert.ok(snap.updatedAt > 0);
const [next, playhead] = snap.timers;
assert.strictEqual(next.id, 'next-cue');
assert.strictEqual(next.kind, 'countdown');
assert.strictEqual(next.state, 'running');
assert.strictEqual(next.label, 'Campus Host');
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead');
assert.strictEqual(playhead.kind, 'elapsed');
assert.strictEqual(playhead.remainingMs, null);
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
});
await test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host
tickFn();
await settle();
const state = handle.state;
assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
});
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = '';
tickFn();
await settle();
assert.strictEqual(handle.state.next, null);
assert.deepStrictEqual(handle.state.cues, []);
assert.strictEqual(handle.state.event, '');
});
await test('health speaks about the upstream, not about itself', () => {
const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful');
});
await test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins;
const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
assert.strictEqual(idle.health().status, 'error');
assert.ok(/login/i.test(idle.health().message), idle.health().message);
idle.stop();
});
await test('stop() leaves nothing running', () => {
handle.stop();
const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error');
});
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
await test('a failed login holds off instead of retrying every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let limited = true;
const client = createResiClient({
username: 'a@b.c',
password: 'secret',
now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
if (limited) return reply({}, { ok: false, status: 429 });
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /rate-limiting/);
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
limited = false;
t += 5 * 60000; // past the longest hold-off
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
const after = logins;
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
assert.strictEqual(logins, after, 'a good session is not signed in again');
});
await test('a rejected password says so', async () => {
const { createResiClient } = require('./resi');
const client = createResiClient({
username: 'a@b.c', password: 'wrong', now: () => 0,
fetch: async () => reply({}, { ok: false, status: 400 }),
});
await assert.rejects(client.venues(), /check the password/);
});
// Seen live: media@waters.church signs in fine but may not list venues (403). The
// client answered every 403 by signing in again -- a fresh login on every poll.
await test('a login that may not see venues is held off, not signed in every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let venueCalls = 0;
const client = createResiClient({
username: 'a@b.c', password: 'secret', now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
// At most one renewal once the session is over a minute old (in case it really
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
assert.match(await poll(), /ask a Resi admin/);
});
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`);
})();
+113
View File
@@ -0,0 +1,113 @@
/* Resi Cues — scoped under its tile via [data-module="resi-cues"], styled with
the shell's CSS variables only.
The countdown is sized in cqmin so it fills whatever the tile is given: the
same module is a small strip on a busy dashboard and a wall panel on its own. */
[data-module="resi-cues"] {
container-type: size;
}
[data-module="resi-cues"] .resi-wrap {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35em;
padding: 2%;
text-align: center;
overflow: hidden;
}
[data-module="resi-cues"] .resi-label {
color: var(--muted);
font-size: clamp(0.6rem, 5cqmin, 1rem);
font-weight: 700;
letter-spacing: 0.14em;
line-height: 1;
}
[data-module="resi-cues"] .resi-cue {
color: var(--text);
font-size: clamp(0.85rem, 11cqmin, 2.6rem);
font-weight: 650;
line-height: 1.05;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="resi-cues"] .resi-count {
color: var(--accent);
font-size: clamp(1.4rem, 30cqmin, 9rem);
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1;
}
[data-module="resi-cues"] .resi-count.is-soon { color: var(--warn); }
/* Held rather than counting: the digits are still true, they just are not moving. */
[data-module="resi-cues"] .resi-count.is-held,
[data-module="resi-cues"] .resi-foot-item.is-held {
color: var(--muted);
}
[data-module="resi-cues"] .resi-foot {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2em 0.9em;
color: var(--muted);
font-size: clamp(0.6rem, 5cqmin, 0.95rem);
font-variant-numeric: tabular-nums;
}
[data-module="resi-cues"] .resi-list {
list-style: none;
margin: 0.4em 0 0;
padding: 0.4em 0 0;
width: 100%;
max-width: 28em;
border-top: 1px solid var(--border);
overflow-y: auto;
font-size: clamp(0.6rem, 4.5cqmin, 0.95rem);
}
[data-module="resi-cues"] .resi-list-row {
display: flex;
justify-content: space-between;
gap: 1em;
padding: 0.12em 0.2em;
color: var(--text);
}
[data-module="resi-cues"] .resi-list-row.is-past { color: var(--muted); }
[data-module="resi-cues"] .resi-list-row.is-next {
color: var(--accent);
font-weight: 650;
}
[data-module="resi-cues"] .resi-list-at { font-variant-numeric: tabular-nums; }
[data-module="resi-cues"] .resi-list-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="resi-cues"] .resi-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 6%;
color: var(--muted);
text-align: center;
}
+471
View File
@@ -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 = '';
},
};
}
+108
View File
@@ -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" }
}
}
+413
View File
@@ -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,
};
+313
View File
@@ -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 &#8211; Thu 9a &#8211; 4:30p</p></div>
`;
const WOONSOCKET = page('<p><strong>Sunday</strong></p><p>9:00a &amp; 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 &amp; 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 &amp; 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 &amp; 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`);
+570
View File
@@ -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(' '),
})),
});
},
};
},
};
+297
View File
@@ -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
View File
@@ -17,6 +17,21 @@
const FLOOR = -200;
/**
* A dead input reads as a number, and a number on a wall is believed. Every
* tile therefore refuses to draw a level while the server says the input is
* digitally silent, and says why instead.
*/
function silentFor(payload) {
return payload && payload.silentSec ? payload.silentSec : 0;
}
function silentText(sec) {
if (sec < 90) return `No audio — ${sec} s`;
const m = Math.round(sec / 60);
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
}
/** Retention per frame for the RTA's smoothing setting. */
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('error', payload.error);
} else if (!payload.frame) {
moduleApi.setStatus('connecting', 'waiting for the first reading');
} else if (silentFor(payload)) {
moduleApi.setStatus('error',
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
} else if (!payload.calibrated) {
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
} else {
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
render(payload) {
const s = moduleApi.instanceSettings;
const ch = pickChannel(payload, s);
const quiet = silentFor(payload);
if (quiet && ch) {
num.textContent = '––.–';
unit.textContent = '';
sub.hidden = false;
sub.textContent = silentText(quiet);
box.dataset.level = 'dead';
fit();
return;
}
if (!payload || payload.error || !ch) {
num.textContent = '––.–';
sub.textContent = payload && payload.error ? payload.error : '';
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
</div>`).join('');
}
const s = moduleApi.instanceSettings;
const quiet = silentFor(payload);
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
const showPeak = s.peakHold !== false;
const lo = payload.calibrated ? 40 : -80;
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
if (!el) continue;
const value = readValue(c, { averaging: s.averaging });
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
const peak = el.querySelector('.spl-bar-peak');
peak.style.display = showPeak ? '' : 'none';
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
el.querySelector('.spl-bar-value').textContent = fmt(value);
el.dataset.level = !payload.calibrated ? 'idle'
el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip);
}
@@ -321,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let painted = false; // spectrogram: has the background been laid down
/**
* The spectrogram's scrolling history, on its own surface.
*
* It used to scroll by copying the visible canvas onto itself, which also
* copied whatever had been drawn on top of it — so every frame dragged the
* frequency labels and gridlines one step further along and left a smear.
* Only measured pixels belong on a surface that scrolls; annotations are
* drawn fresh over the top of it, every frame, and never into it.
*/
let history = null;
let historyCtx = null;
let painted = false; // spectrogram: has the history been cleared yet
let smoothed = null; // RTA: displayed values, damped toward the real ones
let smoothedFor = '';
let axisBtn = null;
@@ -389,10 +429,12 @@ function spectrumView(root, moduleApi, scrolling) {
const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) {
const quiet = silentFor(payload);
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
ctx.fillText(quiet ? silentText(quiet)
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
painted = false;
return;
}
@@ -466,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
// ── spectrogram ──
const timeX = axis() === 'x';
const colW = 2;
const gutter = showLabels ? axisPad : 0;
// Frequency occupies whichever axis time does not.
const freqExtent = timeX ? h - gutter : w - gutter;
const timeExtent = timeX ? w : h;
// A label along the bottom needs only its line height; one down the
// side has to fit "12.5k" beside the plot rather than on top of it.
const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
// Labels belong beside the frequency axis: down the left when frequency
// runs vertically, along the bottom when it runs horizontally.
const plotX = timeX ? gutter : 0;
const plotW = timeX ? w - gutter : w;
const plotH = timeX ? h : h - gutter;
const freqExtent = timeX ? plotH : plotW;
const timeExtent = timeX ? plotW : plotH;
if (plotW < 4 || plotH < 4) return;
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
// Source rect in device pixels, destination in CSS pixels: the context
// carries a devicePixelRatio transform but the canvas as an image
// source does not, and letting drawImage infer them scales the picture
// up and pushes every column off the canvas.
if (timeX) {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
} else {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
// The history surface is sized to the plot, in device pixels, and is
// thrown away whenever that geometry changes — a scroll buffer means
// nothing once it is being stretched into a different shape.
const dpr = window.devicePixelRatio || 1;
const needW = Math.max(1, Math.round(plotW * dpr));
const needH = Math.max(1, Math.round(plotH * dpr));
if (!history || history.width !== needW || history.height !== needH) {
history = document.createElement('canvas');
history.width = needW;
history.height = needH;
historyCtx = history.getContext('2d', { alpha: false });
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
}
if (!painted) {
historyCtx.fillStyle = bg;
historyCtx.fillRect(0, 0, plotW, plotH);
painted = true;
}
// Scroll the history, then add the newest column. The source rectangle
// is in device pixels and the destination in CSS pixels, because the
// context carries the dpr transform but the canvas as an image source
// does not.
historyCtx.drawImage(history, 0, 0, history.width, history.height,
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) {
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1);
historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1);
} else {
// Low frequencies at the left.
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW);
historyCtx.fillRect(i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW);
}
}
if (grid || showLabels) {
const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
};
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
ctx.fillStyle = bg;
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h);
ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
// Repaint the visible canvas from scratch every frame: history first,
// annotations over it. Nothing drawn here can ever reach the scroll.
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.drawImage(history, 0, 0, history.width, history.height,
plotX, 0, plotW, plotH);
if (!grid && !showLabels) return;
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / span;
return timeX ? plotH - t * plotH : plotX + t * plotW;
};
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
if (grid) {
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
ctx.beginPath();
for (const l of labels) {
if (timeX) {
const y = Math.round(l.pos) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y);
ctx.moveTo(plotX, y); ctx.lineTo(w, y);
} else {
const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, h);
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
}
}
ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center';
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
if (showLabels) {
ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.textAlign = timeX ? 'right' : 'center';
for (const l of labels) {
if (timeX) {
ctx.fillText(hzLabel(l.f), gutter - 2,
Math.max(7, Math.min(h - 2, l.pos + 3)));
} else {
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
}
}
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.3.1",
"version": "0.4.1",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+42 -1
View File
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
const PING_MS = 15000;
/**
* Below this, unweighted, the input is not quiet — it is not there.
*
* A real input always carries something: a microphone in a silent room still
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
* a stream of actual zeros reaches the capture tool's -200 floor, and that
* means the device handed us nothing at all.
*/
const SILENCE_DBFS = -150;
/** How long that has to hold before saying so, so a gap between buffers or a
device changing sample rate mid-stream doesn't cry wolf. */
const SILENCE_GRACE_MS = 5000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null;
@@ -100,6 +113,7 @@ module.exports = {
meta: null,
error: '',
streams: new Set(),
silentSince: null,
listener: null,
lastSent: '',
pinger: null,
@@ -143,6 +157,12 @@ module.exports = {
state.error = '';
state.frame = update.frame;
state.meta = update.meta;
// Digital silence on every channel is a dead input, not a reading.
// Saying "ok" through this is what made a stopped Dante device look
// like a frozen module for a day and a half.
const dead = update.frame.ch.every(
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
broadcast();
},
});
@@ -159,6 +179,11 @@ module.exports = {
health() {
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
if (quiet > SILENCE_GRACE_MS) {
return { status: 'error',
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
}
const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated
@@ -351,10 +376,26 @@ module.exports = {
/* ── helpers ────────────────────────────────────────────────────────── */
/** How long every channel has been at digital silence, in ms. */
function silentMs() {
return state && state.silentSince ? Date.now() - state.silentSince : 0;
}
function describe(ms) {
const s = Math.round(ms / 1000);
if (s < 90) return `${s} s`;
const m = Math.round(s / 60);
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
}
function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
// changed every frame would make the fan-out's change check useless.
const quiet = silentMs();
return {
error: state.error,
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
calibrated: state.calibrated,
channels: state.channels,
weighting: state.meta ? state.meta.weighting : '',
+6
View File
@@ -57,6 +57,11 @@
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
/* A dead input reads danger, not muted: silence that is a fault should not
look like silence that is a quiet room. */
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
/* ── level bars ───────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-bars {
@@ -100,6 +105,7 @@
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
/* Peak hold sits on the track rather than inside the fill, so it stays
visible when the bar falls away underneath it. */