Compare commits

..
Author SHA1 Message Date
michael 1410cbc169 Merge pull request 'Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each' (#46) from claude/layouts-menu-not-shown into main 2026-10-11 09:54:06 -04:00
christian 60f82690f3 Merge pull request 'Resi Cues 1.0.3: the next cue's name keeps moving, and Resi logins stop hammering the account' (#43) from claude/resi-cues-startup-pause into main 2026-10-07 17:24:56 -04:00
Waters ChurchandClaude Fable 5.1 7481d1d9e2 Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each
A tile whose module stopped offering its picker entry — Slack's Send message
and Quick replies since the Send Message module took them over — kept its
place in the layout and drew nothing, as designed, but still held its grid
cells with no control anywhere to let them go: a dead area the operator
could not place anything in.

The Layouts menu now has a Not shown section, present only while the layout
holds such a tile. One row per tile: its title (or the module's name plus
the variant, or the module id when it is not installed), why in a few words
— "not offered right now", "module disabled", "module needs a newer
ProdDash", "module not installed" — and a drawn ✕ with a tooltip that
removes it, saves the layout and frees the cells, leaving the menu open as
the Grid Size stepper and Alerts do. The section repaints while the menu is
open whenever tiles become offered or stop being, and only when the list
changed. A tile that merely waits for its module keeps its place as before.

GET /api/modules also returns `inactive` — the installed modules that are
not running, id, name and reason only — so a dashboard can tell a disabled
module from one that is not installed. The toast after loading a named
layout now says the Layouts menu can remove the tiles it did not show.
README and the module guide updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:46:50 -04:00
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
michael c9e03398a6 Merge pull request 'PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard' (#42) from claude/pco-multi-service-types into main 2026-10-06 15:01:09 -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
Waters ChurchandClaude Opus 5 7bb1131378 PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard
An account runs more than one service type — a Sunday morning, a Wednesday
night, a youth service, a second campus — and until now that meant one
ProdDash per type. The module now follows as many as are configured.

Admin picks them in priority order. Shell 1.12.0 gives `multiselect` an
`orderable: true` that stops being a ticklist: the picks sit in a numbered
list with a nudge either way and a remove, over a dropdown holding what is
left. Forty switches to read past is not a picker, and a ticklist cannot
carry an order anyway. The order is the setting — when two types have a plan
at the same date and time, the one higher up wins.

The server gathers candidate plans from every ticked type and tiers them:
today's plan first, then the next upcoming, then the most recent past, with
the configured order breaking a tie. A type that fails is skipped rather
than failing the read, unless it is an auth or rate-limit answer, which
belongs to the whole connection. The types not being followed are read on a
slower lane (45 s) into `state.others`, so they are there to look at without
a second connection.

Every tile's title bar carries a service-type control. Picking one moves
every PCO Plan tile on that browser's dashboard — plan, teams and song key
together — onto its plan, over a small page bus in `viewed-type.js`; the
choice is per browser and survives a reload. The control hides itself when
only one type is configured.

Live machinery stays with the type being followed: another type's plan shows
its running order with no now marker, no AbleSet and no service clock — it
shows that plan's own scheduled time instead, since borrowing the running
clock of a different service is exactly the misreading the amber top-bar
stripe exists to prevent.

Old configs carry `serviceTypeId`; it is read once into the new array and
saved, and plan pins still resolve bare, so nothing has to be re-entered.

`tools/pco-mock.js` takes `--types N` to pad the list out, for trying the
picker against an account with dozens of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:32:19 -04:00
michael f9cd292693 Merge pull request 'Shell 1.11.0: alerts — a module flashes every dashboard listening on one of six channels' (#41) from claude/proddash-alerts-feature-67694f into main 2026-10-05 17:06:54 -04:00
Waters ChurchandClaude Fable 5.1 5ca0a672fa Shell 1.11.0: alerts — a module flashes every dashboard listening on one of six channels
A module that sees something a tech must act on now (a timer overrun, a meter
past its limit, timecode gone) raises an alert on a channel, 1–6, and every
dashboard listening to that channel flashes: the menu bar — brought out for
the duration if it was hidden — and a frame round the screen alternate between
the level's two colours, with the alert's text bold across the bar. Low fades
slowly, medium faster, critical snaps with no fade and floods the board behind
the tiles too, in a muted cut that shows faintly through the tile panels
without costing their content any contrast.

Server: the hub. One alert per channel (a new one replaces the last), timed
out server-side (1–600 s, 15 by default, 0 = held until cleared), broadcast
over the shell stream as `alert` / `alert-clear`; GET /api/alerts for a
dashboard that opens or reconnects mid-alert; POST /api/alerts and
DELETE /api/alerts/<n>, open like a layout save. Modules raise from their
server entry with shell.alert() / shell.clearAlert() or from a tile with
moduleApi.alert() / moduleApi.clearAlert(); a module's alerts end when it
stops. Colours live as alerts.colors in the data directory's proddash.json.

Dashboard: the Layouts menu gains a folded Alerts row with six numbered
toggles; the channel list saves with the layout, local and named (the server
keeps it in a saved layout). Admin: a folded Alerts section with two colour
pickers per level, a swatch running each level's cadence, and a Test row.

Docs: docs/ALERTS.md (the feature), a "Raising alerts" section in the module
guide, README. Tools: tools/alert.js raises/clears/lists from the command
line; tools/alerts.test.js drives it all end to end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 16:05:12 -04:00
michael a3500f98b1 Merge pull request 'Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers' (#40) from claude/send-message-module-refactor-9af19a into main 2026-10-05 14:13:17 -04:00
Waters ChurchandClaude Fable 5.1 1274360ee6 Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers
A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.

Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.

ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.

The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.

The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 13:55:55 -04:00
michael 3b81efcb51 Merge pull request 'Update check: say what the forge answered, not zlib's "incorrect header check"' (#34) from claude/update-error-message into main 2026-09-30 14:23:04 -04:00
Waters Church 409f2c258e Merge remote-tracking branch 'origin/main' into claude/update-error-message
# Conflicts:
#	package.json
2026-09-30 14:22:25 -04:00
michael 7f6e5bd151 Merge pull request 'Shell 1.10.1: header menus open where their button is, folded bar or not; ProdCom 1.3.3 keeps its filter on a narrow tile' (#39) from claude/prodcom-filter-button-visibility-8a1d2d into main 2026-09-30 14:21:52 -04:00
michael 2307770ce9 Merge pull request 'SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel' (#38) from claude/spl-prebuilt-helper into main 2026-09-30 14:20:46 -04:00
michael fe3811b03e Merge pull request 'Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel' (#37) from claude/timers-prebuilt-helper into main 2026-09-30 14:19:52 -04:00
Waters ChurchandClaude Fable 5.1 41811ead1c Shell 1.10.1: header menus open where their button is, folded bar or not; ProdCom 1.3.3 keeps its filter on a narrow tile
A tile's dropdown menus (ProdCom's Channels, the Timers menu) opened but
never showed under a folded-away title bar — in fullscreen, or with
auto-hide on. The bar carries a backdrop-filter and a transform while
folded, and either one makes a fixed-positioned descendant position
itself against the bar instead of the viewport: the menu landed a
screen-height below its button, inside the tile's overflow. A tile near
the top of the screen showed a misplaced menu; anything lower showed
nothing at all.

The shell now hosts every header menu on the body, under an element
carrying the tile's data-module so a module's scoped CSS still reaches
its items; places it whole inside the viewport; holds a folded bar open
while one of its menus is open (the pointer has to travel down into the
menu); and marks the open menu's button the way the gear is marked.

ProdCom: seven controls need ~175 px of bar, and the shell clips what a
narrow tile cannot fit — the Channels button went with the rest. Below
380 px the six secondary controls fold into a ⋯ menu offering the same
actions, so the filter is the last thing to go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 14:08:02 -04:00
Waters ChurchandClaude Fable 5.1 0929af46ca SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS
app build had compiled it, with the module trying swiftc on the booth as a
fallback — NA has been logging "spawn spl-capture ENOENT" since its
catalog install. Under the module guide's rule it now ships built:
bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with
the Developer ID (hardened runtime, audio-input entitlement), from
native/spl-capture.swift via native/build.sh. captureTool() picks the
binary for this platform; where there is none — Windows, until a Windows
implementation exists — measurement reports itself off in the health line
and the device picker while the module stays mounted. The runtime build
and its "building…" state are gone, and the old top-level binary is no
longer gitignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:12:42 -04:00
Waters ChurchandClaude Fable 5.1 2817a444c3 Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel
The tool used to be compiled by the macOS app build, or by the module
itself on the booth with swiftc — and a catalog install got source only,
which the unbundled 1.10.0 shell made every fresh install. Under the module
guide's rule it now ships built: bin/ltc-capture-macos-arm64 and
bin/ltc-capture-macos-x86_64, signed with the Developer ID (hardened
runtime, audio-input entitlement), from native/ltc-capture.swift via
native/build.sh. Nothing is built or looked for on the machine any more:
captureTool() picks the binary for this platform, and where there is none
— Windows, until a Windows implementation exists — timecode reports itself
off in the tile and the health line while the rest of the module runs.
The runtime build, its "building…" states and the device picker's build
kick are gone with it. The old top-level binary is no longer gitignored
(it is no longer written).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:11:08 -04:00
michael c11389473f Merge pull request 'Release builds: Intel disk images, a Windows bundle, and where to download them' (#24) from claude/release-builds into main 2026-09-30 09:58:11 -04:00
Waters ChurchandClaude Fable 5.1 c6a44e6ddc Windows build script: executable, like the Mac one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:54:51 -04:00
Waters ChurchandClaude Fable 5.1 2f14b30d68 Windows bundle: no modules either, and the read-me says where they come from
The same change the Mac build got in 1.10.0: the bundle carries the shell
alone; the admin page's catalog installs modules into %APPDATA%\ProdDash.
README.txt gains the step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:46:51 -04:00
Waters Church b5d39c60dc Merge remote-tracking branch 'origin/main' into claude/release-builds 2026-09-30 09:46:12 -04:00
michael 407eaecc23 Merge pull request 'Shell 1.10.0: a packaged ProdDash carries no modules' (#36) from claude/unbundled-shell into main 2026-09-30 09:42:23 -04:00
Waters ChurchandClaude Fable 5.1 ac1f17b9ed Shell 1.10.0: a packaged ProdDash carries no modules
The macOS app and the Windows bundle shipped every module in the repo, on
by default, and a shell update silently added whatever new module had
landed on main — Band Lineup appeared on AB that way. A module was also
only as fresh as the last shell update. Now a packaged install (no .git —
the same test the updater uses to choose the archive over git pull)
carries no modules at all: every one arrives from the admin page's catalog
into the data directory, and the shell's own update never writes modules/.
A git checkout keeps running the modules in its tree.

Existing installs move over by themselves: at the first start, the bundled
modules that are switched on are copied into the data directory as
installed copies (a compiled helper keeps its mode), an uninstalled one is
dropped, and the app folder's modules/ is removed. Settings are keyed by
module id, so nothing changes for the operator.

Around it: the dashboard's empty state says "No modules yet — open Admin"
when the registry is empty; the admin page says "Nothing installed yet",
puts a checkbox on every compatible catalog entry and an Install selected
button (POST /api/admin/modules/install {ids}) that fetches them in one go.
The Mac build stops copying modules/ and no longer compiles or signs Swift
helpers — those ship prebuilt inside a module, per the guide.

tools/unbundled.test.js drives a packaged copy of the shell through the
move, an archive update that must not bring modules back, and a multi-install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:34:10 -04:00
michael c37575f662 Merge pull request 'Module rules: a native helper ships prebuilt, for Apple silicon and Windows' (#35) from claude/module-helper-rules into main 2026-09-29 16:45:35 -04:00
Waters ChurchandClaude Fable 5.1 07e4c78417 Module rules: a native helper ships prebuilt, for Apple silicon and Windows
The machine running ProdDash is a packaged app or the Windows bundle plus
modules from the admin page's catalog. It has no compiler, and the catalog
delivers exactly the folder that is in the repo — so a module's native
program (audio capture, a decoder) ships already built, inside the module
folder, one file per platform: bin/<name>-macos-arm64 and
bin/<name>-win-x64.exe are required, Intel macOS optional, source and the
script that rebuilds every binary under native/.

The guide's rule 3 now says so, "The shape of a module" shows the layout,
and a new "Native helpers" section gives the contract: pick the binary by
platform at runtime, report the feature off when there is none — never
build, never search the machine, never ask the operator to install
anything — one command-line contract in any language per platform, bump
the version when a binary changes, keep them lean, sign the macOS one.
CLAUDE.md carries the one-line version. Rules only: no module changes here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 16:34:34 -04:00
michael 9ca301b4be Merge pull request 'PCO Plan 1.9.0: a Song Key tile, keys as notes, and AbleSet-shaped section chips' (#32) from claude/pco-song-key into main 2026-09-29 14:58:47 -04:00
Waters ChurchandClaude Opus 5 ece2c0a2c3 Centre the NOW tag; the type badge was already fine
Re-measuring the badges properly changed the answer. The earlier reading
that said the type badge sat 0.52px high was measuring the wrong glyphs: it
fed the canvas the element's raw text ("Song", "Media") while the page
renders text-transform: uppercase ("SONG", "MEDIA"), and a lowercase 'd' or
'g' has an ascender and a descender the capitals never draw.

Measured against the text as actually rendered, the type badge is within
0.10px of centred and wants nothing. The NOW tag was the one off, by 0.59px,
the same way the section chips were. So: the badge padding added in the last
commit is reverted, and the NOW tag gets the correction instead.

That line now reads, in px off centre: NOW 0.01, type badge 0.10, key badge
0.16, section chip -0.01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 32180bf299 Centre the text in the song-section chips
The chips looked top-heavy, and measuring said so: the capitals sat 5.12px
below the top of the chip and 4.22px above the bottom. Uppercase text has no
descenders, so the font's descent allowance below the baseline is empty
space the letters never use, while the gap between the ascender line and the
cap height sits above them.

The padding is now a touch shallower on top and deeper below, which puts the
letters themselves in the middle: 4.67px above, 4.68px below. In em, so it
holds wherever the tile's text size is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 06066529bc Keys read as notes on the Plan tile's badge too
The item badge still showed Planning Center's raw "Bb" and "F#m" while the
Song Key tile beside it showed B♭ and F♯m. One module should not spell the
same thing two ways.

The notation moves into key-notation.js and both tiles import it, rather
than each keeping its own copy of the parse: the day one of them changes,
they all should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 4e5b60e82b PCO Plan 1.9.0: a Song Key tile, and AbleSet-shaped section chips
**Song Key**, a third entry in the + picker: the key of the item
ProPresenter has live, as large as the tile allows. It is set as a note —
B♭, F♯m rather than Bb and F#m — with the song's name and the next keyed
song under it. All three are tile settings: the colour, the name and the
next key. There is no new connection; it reads the same streamed state the
Plan tile does.

The accidental and the quality sit on the same line as the letter. Raising
the accidental read as two stacked characters at tile sizes, which is worse
than plain at a glance from the back of a room.

When the ProPresenter feed that picks the item goes away the key stays up —
the band still needs it — but faded, and the tile's dot says it is the last
item rather than the current one.

**The section chips** on the Plan tile's current item are rounded squares
now, with the tinted ground and transparent border the AbleSet module gives
its own section pills, so the two read as one family. The chip playing keeps
its own ground so its progress fills against the chip rather than against
the row behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Fable 5.1 601dc9e8b0 Update check: say what the forge answered, not zlib's "incorrect header check"
A Gitea that requires a sign-in does not refuse the branch archive: it
redirects the download to its login page, which arrives as a 200 of HTML,
and the admin page then reported zlib failing to unpack it — "incorrect
header check". Both booths showed exactly that on 29 Sept once the sign-in
requirement was switched on.

The updater now names the case: "… requires a sign-in to read
michael/ProdDash@main — turn off 'require sign-in to view' in Gitea, or
make the repo readable without one". Any other answer that is not a gzip
archive says what came back instead — a web page, or the content type —
and where to look.

tools/update-check.test.js drives the check against a mock forge: the login
redirect, a bare HTML answer, an HTTP error, and a real archive that must
still parse and be offered.

Shell 1.9.3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 14:54:05 -04:00
michael 92b98b6579 Merge pull request 'SPL Meter 0.4.3: a failed capture-tool build must not park the listener forever' (#31) from claude/spl-build-retry into main 2026-09-28 11:31:49 -04:00
michael 58f3e5010a Merge pull request 'SPL Meter 0.4.2: the device picker can build the tool it needs' (#18) from claude/spl-devices-without-binary into main 2026-09-28 11:30:36 -04:00
michael 7cc19ef696 Merge pull request 'Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden' (#33) from claude/timers-video-countdown into main 2026-09-27 11:35:52 -04:00
Waters ChurchandClaude Fable 5 86a3270f1c Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden
Time left on the video ProPresenter is playing — the one number a booth
needs during a bumper or sermon video — was missing: the module only read
the configured timers, and the video countdown lives in the transport
routes. Verified live on ProPresenter 21.4:
  GET /v1/transport/presentation/current → { is_playing, uuid, name,
    artist, audio_only, duration }
  GET /v1/transport/presentation/time    → bare seconds as a sub-second
    float — better than /v1/timer/video_countdown's whole-second string,
    so no quantization phase lock is needed, only an anchor hold (a frame
    is broadcast only when the anchor drifts, not per poll).

The Video card rides the ProPresenter source: transport current on the
slow lane, position on the hot path (skipped while no media is loaded),
running/paused/idle from is_playing and position, the media name (minus
its file extension) as the detail line. It persists as an idle '—'
between videos — a wall tile must not appear and vanish with the media —
and is absent only on a ProPresenter whose transport routes 404.

No looping flag exists anywhere in the API, so looping is inferred: the
same media's position jumping backwards is a wrap, and the flag sticks
until the media changes. Each tile gets a 'Looping videos' switch
(default off): off, a looping background's meaningless time is hidden —
the card sits out of a multi-card tile, and a solo Video tile shows a
quiet 'Looping' card instead of sitting empty. Shown loops never take
the warn/overrun tones: a 15 s background flashing red every wrap would
cry wolf all service.

The picker offers 'Video' as its own tile (solo gear carries the loop
switch); pp-timers-mock gains the transport routes with a 4-minute cycle
(bumper playing → paused → resumed → nothing → looping background).
Verified in the browser across the whole cycle, both tile kinds, and the
per-tile toggle. Selftests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:32:36 -04:00
Waters ChurchandClaude Fable 5 e9d6edc2c8 SPL Meter 0.4.3: a failed capture-tool build must not park the listener forever
Same deadlock the Timers module hit live on AB (fixed there in 1.5.1):
start() skipped scheduling its retry while a build was in flight, and
ensureCaptureBinary's onReady only fires on build SUCCESS. A failed
build therefore left the listener waiting forever on a callback that
never comes — "Building the capture tool…" indefinitely — and a binary
scp'd in from outside (the required path on AB, whose CLT cannot
compile) was never picked up without a module remount.

onReady stays as the fast path; the 30-second retry is now armed in
every not-ready state, so a failed build keeps re-checking and an
externally supplied binary is found within half a minute.

Reproduced pre-fix (build fails at 0s, binary copied in at 3s, still
untouched at 38s) and verified post-fix (retry at 30s spawns it).
spl-capture --selftest unchanged against the IEC 61672 table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:03:57 -04:00
michael b3b7b29007 Merge pull request 'Timers 1.5.1: a failed capture-tool build must not park the listener forever' (#30) from claude/timers-build-retry into main 2026-09-27 10:59:17 -04:00
Waters ChurchandClaude Fable 5 62c34fe80c Timers 1.5.1: a failed capture-tool build must not park the listener forever
Found live on AB: the build failed (its CLT is broken), the compiled
binary was then copied in by hand — and the listener never noticed. It
had seen 'build in flight' and stopped scheduling retries, waiting for
ensureCaptureBinary's onReady, which only fires on SUCCESS. The tile said
'Capture tool building…' indefinitely with a working binary on disk; only
a module remount recovered it.

onReady stays as the fast path; the 30 s retry now stays armed in every
not-ready state, so a failed build keeps re-checking and a binary that
arrives from outside (scp'd to a machine that can't compile) is picked up
within half a minute.

Reproduced the exact shape in a swiftc-less environment: build fails,
binary copied in at 3 s, retry at 30 s spawns it (the capture's own
unknown-device answer proves the real binary ran) — previously a
permanent deadlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:55:44 -04:00
michael 4c6a2b8f76 Merge pull request 'Shell 1.9.2: a module update keeps per-machine executables' (#29) from claude/shell-keep-executables into main 2026-09-27 10:49:07 -04:00
Waters ChurchandClaude Fable 5 b0defbd56b Shell 1.9.2: a module update keeps per-machine executables
AB updated Timers from the catalog and LTC died: the update replaces the
module folder wholesale, which deleted the compiled ltc-capture binary —
and AB's Xcode CLT cannot rebuild it (the SwiftBridging modulemap defect;
the module's new self-build correctly runs there and fails with exactly
that error). The binary on AB has always been compiled elsewhere and
shipped, so 'update' was guaranteed to break LTC on that machine.

installModule now carries executables (any execute bit) the download does
not include over from the copy being replaced — or from the app's bundled
copy on a first install — preserving path and mode. Executables only, on
purpose: those are the gitignored per-machine build artifacts (ltc-capture,
spl-capture), while stray text files from an old version — code an update
deliberately removed — must not be resurrected. The Timers module's
staleness check still notices a carried binary older than its updated
source and rebuilds behind it where the toolchain works.

Verified end-to-end against the real Gitea catalog: an installed 1.4.9
copy holding a fake executable and a stray .js file, updated to 1.5.0 —
the executable survives with its mode and content, the stray file is gone,
and the log names what was kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:41:41 -04:00
michael 914ff7795b Merge pull request 'Shell 1.9.1: a module update actually runs the updated code' (#28) from claude/shell-module-update-reload into main 2026-09-27 10:30:54 -04:00
Waters ChurchandClaude Fable 5 076e583daf Shell 1.9.1: a module update actually runs the updated code
AB updated the Timers module to 1.5.0 and its new LTC capture-tool
self-build never ran: mountModule re-required the module's server entry,
and Node's require cache handed back the OLD code — the catalog update
had swapped the folder on disk, but the process kept executing what it
loaded at startup until ProdDash itself restarted. The manifest, read
fresh from disk, meanwhile reported the new version, so the admin page
claimed an update that functionally hadn't happened. Every module update
to date has silently depended on a follow-up app restart.

mountModule now drops the module directory's entries from require.cache
before requiring, so every (re)mount runs the files on disk — nested
requires (ltc-listener.js and friends) included. Modules already must
come up clean on re-init per the module guide, so config-change remounts
see the same contract, minus the trap.

Verified with a probe module in a scratch data dir: change a nested
require on disk, PUT config to remount, the route serves the new code
(it served the stale code before the fix). The Timers module re-inits
cleanly across repeated fresh-code remounts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:26:38 -04:00
michael 21519dd3da Merge pull request 'Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line' (#27) from claude/timers-capture-install into main 2026-09-27 10:08:25 -04:00
Waters ChurchandClaude Fable 5 8843339f11 Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line
A catalog install ships source only, and a catalog update deletes the
module folder — a previously built ltc-capture binary with it — so every
install or update left LTC dead until someone compiled the tool by hand.
The build also still used the blocking spawnSync that once froze the whole
server (the bug spl-meter fixed in 0.4.0), and only ran when a listener
started: no tool meant no device list, and no device meant no listener.

ensureCaptureBinary() (module scope in ltc-listener.js, the spl-meter
0.4.2 pattern) now builds the tool in the background as part of coming
up: kicked when the LTC source starts and by the admin device picker,
single-flight, built to a temp name and renamed so a killed build can't
leave a truncated binary, and rebuilt behind a still-serving binary when
a module update leaves the source newer. The waiting listener starts
itself the moment the build lands.

Tile-side warnings are now one line — 'Not configured — see Admin',
'Unreachable', 'Capture tool building…' — with the client's source-label
prefix no longer repeated. The full diagnostics (host:port, errno, the
capture tool's own words, build instructions) move to the admin health
line and the log, where the person who can act on them is looking.

Verified: build from the exact catalog-install state (source only) lands
and lists 12 real devices; touching the source triggers an atomic rebuild
while the old binary keeps serving; /ltc/devices kicks the build and
answers with the device list; live server shows terse source messages on
the tile and the detailed story in /api/admin/state health. ltc and phase
selftests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 09:47:05 -04:00
michael 4ed22b361b Merge pull request 'PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile' (#25) from claude/pco-ableset-progress into main 2026-09-27 09:15:56 -04:00
Waters ChurchandClaude Opus 5 aec9c47ed4 PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile
Three things, the first on its own.

**Auto-scroll moves the tile, not the board.** `scrollIntoView` walks up and
scrolls every ancestor that can scroll, so bringing the current item into
view dragged the whole dashboard with it whenever the board was taller than
the window. The list's own `scrollTop` is moved instead, measured from
rectangles because the rows' offset parent is the tile rather than the list.

**The current item fills as its song plays.** When an AbleSet module is
installed, configured and playing a song that IS the item on screen, that
item's background fills left to right with the song. The song is tied to a
plan item by the same name matching the live ProPresenter document already
uses, so a bar can never quietly fill the sermon while the band rehearses
something else.

**Its sections slide past, bottom right of that item.** Every section of the
song as a strip, the played ones dimmed, the one playing filling in turn and
kept centred — by moving that strip's own `scrollLeft`, for the same reason
as above. The chips are rebuilt only when the song changes; moving to the
next section just moves the classes, so the strip keeps its place.

Both are switches in the tile's gear, **Song progress** and **Song
sections**, and both draw nothing at all unless there is an AbleSet module
to follow: the server reports `available: false` for no module, a disabled
one, or one with no host, and re-checks every 15 s so installing AbleSet
later lights it up without a restart.

The server follows the AbleSet module over loopback exactly as it follows
ProPresenter's, and republishes a small slice — song, sections, playhead in
beats with a stamp — as its own `ableset` SSE event. Its own event because
it moves five times a second and must never drag the plan (items, teams)
onto the wire with it; the tile carries the playhead forward from the tempo
between packets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 09:14:19 -04:00
michael 5f60cd563c Merge pull request 'Timers 1.4.2: ProPresenter digits no longer flick back a second' (#26) from claude/timers-second-phase into main 2026-09-27 09:05:46 -04:00
michael de32046553 Merge pull request 'AbleSet 1.0.0: follow AbleSet's setlist over OSC' (#17) from claude/proddash-ableset-module-6c2556 into main 2026-09-27 09:02:23 -04:00
Waters ChurchandClaude Opus 5.5 c1d9c59325 Merge origin/main into claude/proddash-ableset-module-6c2556
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:01:35 -04:00
Waters ChurchandClaude Fable 5 2f4c357bf5 Timers 1.4.2: ProPresenter digits no longer flick back a second
ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.

phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.

phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 08:46:50 -04:00
Waters ChurchandClaude Fable 5.1 b5c539ba05 Release builds: Intel disk images, a Windows bundle, and where to download them
launcher/macos/build.sh takes ARCH=x86_64 for Intel Macs — the Swift target
and the embedded Node follow (nodejs.org calls that one darwin-x64). The
default stays arm64.

launcher/windows/build.sh is new: the same payload plus Node for Windows,
checksum-verified against nodejs.org, and ProdDash.bat, which keeps the
server running and starts it again on exit code 75 — the admin page's update
restart — the way the macOS launcher does. No Windows launcher app yet.

The README's Run it section now points at the Releases page and the
ProdDash-releases repo that holds the files, one line each for Apple
silicon, Intel and Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:53:19 -04:00
michael 7a736b3db1 Merge pull request 'PCO Plan 1.7.0: a Teams tile — who is scheduled, with photos' (#23) from claude/pco-teams-tile into main 2026-09-24 15:27:59 -04:00
Waters ChurchandClaude Opus 5 6c0aee5abf Teams tile: one Colors group, no Positions switches
Colors and Status colors were two groups asking the same question, so they
are one: team header, the tints and the three status colours together.

The Positions switches go entirely — the team switches already decide what
a tile shows, and "Group by position" still arranges what is left. With
them go GET /positions and positionOptions().

Two rules the tile was breaking on the way past: a gear group may carry no
`help` (rule 5), and only hide-toggles belong in Show (rule 8) — so the
group blurbs are gone and the Columns picker sits loose at the top beside
the team switches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:15:34 -04:00
Waters ChurchandClaude Opus 5 6d0f8828c1 PCO Plan 1.7.0: a Teams tile — who is scheduled, with photos
The module now offers two tiles in the + picker, Plan and Teams, sharing
the one Planning Center connection and the one poll.

Teams draws the current plan's team and position assignments: each team as
a band of its people, with the person's photo, their position and a dot for
confirmed / unconfirmed / declined. Photos are proxied and cached by the
module (GET /team-avatar/<planPersonId>) so no browser ever talks to
Planning Center; a person with no photo gets their initials.

Everything shown is the tile's own choice, so two tiles can differ:
- Teams and Positions — a switch per team and per position, all on until
  someone flips one (GET /teams, GET /positions feed the lists)
- Colours — a picker for the team headers, or Tint teams to give each its
  own, optionally carried into the rows, plus the header fade and pickers
  for the three statuses
- Show — twelve switches (top bar, team headers and counts, avatars,
  positions, group by position, status, notes, declined and unconfirmed
  people, teams nobody is on, columns)

Server: team_members?include=team,person rides a slow lane (re-read on a
plan change, else at most once a minute) and can never fail the plan read —
a lost roster keeps the last good one and says so. The service type's own
team list is read too, cached ten minutes: Planning Center only returns a
team once somebody is scheduled on it, so that list is the only way to show
a team nobody has signed up for.

No shell changes: picker entries carrying their own instanceSchema, the
colour field and multiselects all already exist.

tools/pco-mock.js grows team_members, the service type's teams and stand-in
avatar images, including a declined person, people with no photo and a team
nobody is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:42:57 -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
Waters ChurchandClaude Opus 5.5 9192eed318 AbleSet 1.0.1: a remounted tile asks for the state instead of waiting
Every gear change remounts the tile, and the remounted tile joins the
page's shared stream mid-flow, so it heard nothing until AbleSet next
changed — with the transport stopped, never. Tiles went blank until a
reload. The tile now fetches /state when it starts and whenever the
stream (re)opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:07:13 -04:00
Waters ChurchandClaude Opus 5.5 41fd76817c AbleSet 1.0.0: follow AbleSet's setlist over OSC
A new module that subscribes to AbleSet's OSC API (UDP 39051) and streams
what it is playing to ten tiles: Now Playing (song, tempo, meter, duration,
time left, section, progress bar, next song, songs left), Song Sections
(a sliding strip, or a lottery wheel from the title bar), Setlist,
Transport, Tempo, Song Time, Set Time, Section, Next Song and Status, each
coloured by its state. The song and set countdowns are offered to the
Timers module.

A heartbeat watchdog resubscribes when AbleSet goes quiet (a restarted
AbleSet forgets its subscribers). Time left falls back to the song length
and playhead when AbleSet stops sending it (Live disconnected).

tools/ableset-mock.js plays a setlist over the same API for testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:24:19 -04:00
88 changed files with 16460 additions and 1980 deletions
+2 -6
View File
@@ -8,12 +8,8 @@ dist/
config/modules.json
config/layouts/
# compiled per-machine, source sits next to it
modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/
launcher/macos/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
launcher/windows/build/
launcher/windows/.cache/
+9 -2
View File
@@ -3,7 +3,8 @@
Modular production dashboard web app for Waters Church, developed in this repo.
The app lives at the repo root and is built: zero-dependency Node server
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
`/admin`, modules in `modules/` (the repo's copies — a packaged install ships
none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
the server and its own Node runtime, supervises them, and owns the macOS
permissions modules declare; see its README). Runtime state (module
@@ -28,7 +29,12 @@ title-bar controls). The rules that matter most:
ship inside the module folder** (zero deps is the norm). **Everything the
module does runs inside its server entry** — no helper scripts, companion
programs or daemons the operator starts by hand; a listener or decoder is
started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"`
started in `init()` and stopped in `stop()`. **A native helper ships
prebuilt inside the module folder for Apple silicon macOS and 64-bit
Windows** (`bin/<name>-macos-arm64`, `bin/<name>-win-x64.exe`, source and
build script under `native/`); nothing is ever compiled on the machine
running ProdDash, and a missing binary means the feature reports itself
off, not a broken module. Declare `"proddash": ">=x.y.z"`
and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side).
@@ -45,6 +51,7 @@ title-bar controls). The rules that matter most:
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
## Reference code (read-only forks — don't develop these here)
+231 -52
View File
@@ -30,11 +30,23 @@ and a clear degraded state, then recover on their own.
## Run it
Ready-made builds are on the
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
the files themselves live in
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
bundle for 64-bit Windows. Every build updates itself afterwards from the
admin page, so the download only has to be recent, not current.
### macOS — the app
Open the disk image and drag **ProdDash** to Applications. That's the whole
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
**ProdDash** to Applications. That's the whole
installation: the app carries the dashboard and its own Node runtime, so
nothing else has to be installed and nothing can be missing. It lives in the
nothing else has to be installed. It carries no modules — open **Admin →
Available modules**, tick the ones this machine needs and press **Install
selected**; each updates on its own from then on. It lives in the
menu bar, starts and stops the server, can open at login, and holds the
permissions the modules need — the microphone the LTC listener reads, and
local network access for ProdCom and ProPresenter. macOS grants those to the
@@ -43,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
zeroes.
Build the disk image from a checkout with
[launcher/macos](launcher/macos)/`build.sh`:
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
for Intel):
```bash
cd launcher/macos && ./build.sh --dmg
```
### Windows — the bundle
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
to — `C:\ProdDash`, say, not Program Files, because updates write into the
folder — and double-click **ProdDash.bat**. The window it opens is the
dashboard running: it stays open, and it starts the server again when the
admin page applies an update. Allow `node.exe` on private networks when the
firewall asks, so other screens can reach it. Settings and layouts live in
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
menu-bar-style launcher for Windows yet; the bundle is built with
[launcher/windows](launcher/windows)/`build.sh`.
### Anywhere else, or from a checkout
Requires [Node.js](https://nodejs.org) 18 or newer.
@@ -103,6 +129,7 @@ with this machine's shell settings — the same keys as the checked-in
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
| `alerts` | `{ "colors": { "low": [a, b], "medium": [a, b], "critical": [a, b] } }` — the two colours each alert level flashes between, as `#rrggbb`. The admin page's **Alerts** section writes this one — see [Alerts](#alerts). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
@@ -166,6 +193,25 @@ saw and applies it before the server answers, so nothing flashes. Modules
style with the shell's colour variables, which is why they follow; a colour
set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
### Alerts
A module that sees something a tech must act on now — a timer overrun, a
meter past its limit, timecode gone — can raise an **alert** on one of six
channels, and every dashboard listening to that channel flashes: the menu
bar (brought out if it was hidden, for as long as the alert lasts) and a
frame round the screen alternate between two colours, with the alert's text
bold across the bar. **Low** fades slowly between its two colours,
**medium** faster, **critical** snaps between them with no fade and floods
the board behind the tiles as well, in a muted cut of the two that shows
faintly through the tiles without costing their content any contrast. The admin
page's **Alerts** section (folded, near the bottom) holds the two colour
pickers for each level, with a swatch running that level's cadence, and a
**Test** row that flashes any level on any channel for eight seconds. The
choice is stored as `alerts.colors` in the data directory's `proddash.json`.
Which channels a dashboard listens to is chosen on the dashboard itself —
see *Using the dashboard* below — and [docs/ALERTS.md](docs/ALERTS.md) has
the whole feature, module side included.
## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its git
@@ -179,13 +225,15 @@ below. Nothing is installed without someone clicking.
- **Available modules** lists modules in the repo that aren't installed
here. **Install** downloads the module into the data directory's `modules/`
and starts it right away — nothing is written into the app folder, and no
restart is needed. Modules that ship with your copy of ProdDash are
"bundled"; ones you installed are "installed"; if both exist, the newer
version runs.
restart is needed. Tick several and press **Install selected** to fetch
them in one go. A packaged ProdDash (the app, the Windows bundle) ships
with no modules at all: this list is how every one of them arrives, and a
ProdDash update never brings one along. A git checkout also runs the
modules in its own tree ("bundled"); if both exist, the newer version runs.
- **Uninstall** on a module card removes an installed module's files. For a
bundled module (its folder belongs to the app) it hides the module from
every dashboard instead; it reappears under Available modules and can be
brought back without downloading. Settings are kept either way.
checkout's bundled module (its folder belongs to the repo) it hides the
module from every dashboard instead; it reappears under Available modules
and can be brought back without downloading. Settings are kept either way.
- **Module updates**: when the repo has a newer version of an installed
module, its card shows **Update to vX**.
- **Version requirements**: every module declares the ProdDash versions it
@@ -268,10 +316,15 @@ below. Nothing is installed without someone clicking.
name, load a named layout (it becomes a local copy — browsers are never
linked), or reset this browser's dashboard. Loading says what it did —
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
now* — with **Undo** in the message to get back the arrangement you had.
A tile whose module or picker entry isn't offered at the moment keeps its
place and shows nothing, then appears when it is; it is never replaced by
the module's default tile. Saving over an existing name asks first, and
now; the Layouts menu can remove them* — with **Undo** in the message to
get back the arrangement you had. A tile whose module or picker entry
isn't offered at the moment keeps its place and shows nothing, then
appears when it is; it is never replaced by the module's default tile.
While any tile is in that state the same menu has a **Not shown** section
listing each one with why — *not offered right now*, *module disabled*,
*module not installed* — and a ✕ that removes it and frees its cells, for
the tile that is not coming back (a module that stopped offering that
kind of tile). Saving over an existing name asks first, and
the server keeps the copy it replaced: **Restore previous** in Admin →
Saved layouts swaps it back (and swapping again undoes that). The menu's
**Grid Size** stepper sets how many columns this browser cuts the board into —
@@ -281,6 +334,14 @@ below. Nothing is installed without someone clicking.
that no longer fits where it was moves to the first free spot rather than
onto another tile. It saves with the layout, so a named layout comes back
at the density it was arranged on.
- The same menu's **Alerts** row says which of the six alert channels this
screen listens to, and opens to six numbered buttons that toggle them
without closing the menu. An alert a module raises on a chosen channel
flashes this dashboard's menu bar and screen edge with its text in the
bar; one on any other channel leaves it alone. A fresh dashboard listens
to nothing. The choice saves with the layout, local and named — a booth
loading its "Stage" layout gets the stage's channels with it. See
[Alerts](#alerts).
- Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect
automatically.
@@ -290,7 +351,13 @@ below. Nothing is installed without someone clicking.
- **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size, a top-down / bottom-up direction toggle
and jump-to-latest in the title bar.
and jump-to-latest in the title bar. Text typed into a channel (from a
dashboard, or ProdCom's own box) is marked with a keyboard; an automation's
with a bolt. The module also offers its channels to **Send Message** as
identities: a click anywhere in the transcript points that module's tiles
at ProdCom (the tile's bottom edge lights up in the accent color), and the
channel the text goes out *as* is picked from their Send as menu — only
channels ProdCom lets text be typed onto are offered.
Admin config: ProdCom server (host : port), API key.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress,
@@ -303,9 +370,11 @@ below. Nothing is installed without someone clicking.
status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are
elapsed), the **video countdown** (time left on what ProPresenter is
playing, with a per-tile "Looping videos" switch so looping backgrounds
stay hidden) and LTC timecode as auto-sized cards. The + picker lists
every discovered timer (and the LTC card) as its own tile, or add "All
timers" and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config is in
three groups: **ProPresenter** (endpoint host : port, API password),
**LTC Timecode** (the listener switch, and — when it's on — the audio
@@ -323,7 +392,11 @@ below. Nothing is installed without someone clicking.
session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
- **PCO Plan** — the order of service from Planning Center Services: every
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
Key** (the key of the item on screen).
**Plan** shows every
plan item with its scheduled start time, length, type, song key,
description and item notes. When the ProPresenter module is running it
follows that module's live item, highlights the matching plan item, dims
@@ -335,50 +408,154 @@ below. Nothing is installed without someone clicking.
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
config is the connection: Planning Center credentials (Personal Access
Token app ID + secret, or an OAuth token), the service types to follow (a
list grouped by your Planning Center folders) and an optional pinned plan.
**Tick as many service types as you like** — the module follows whichever
of them has a plan soonest, so a campus running Sunday mornings, a
Wednesday night and a youth service needs one dashboard, not three. The
ticked types are **numbered in priority order**, nudged up and down with
the arrows beside them, and that order decides the winner when two of them
have a plan at the same date and time. Every tile's title bar then carries
a **service-type control** for reading one of the others: pick a type and
every PCO Plan tile on *that* browser's dashboard — plan, teams and song
key together — moves to its plan, marked with an amber stripe, while the
machine goes on following the live one. The choice is that browser's
alone, it sticks until changed, and the control hides itself when only one
service type is ticked. Live machinery belongs to the followed type:
another type's plan shows its running order without the now marker, the
service clock or AbleSet. Everything about the display is per tile
(gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
of each per dashboard): **Send message**, a free-text box with the admin's
quick emojis one press away, and **Quick replies**, a grid of preset
buttons. Both post to the channel of whichever transcript was clicked last
— its bottom edge lights up in the accent color — and to the first
configured channel until one is clicked. Messages arrive with avatars,
names and times, Slack's inline formatting and the workspace's custom
emoji; a right-click on one opens the reaction menu, and text size and
jump-to-latest sit in the title bar. Nothing leaves the machine until the
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
until the auto-lock idles out, a reload asks again, and the lock button on
a send tile's title bar ends it early.
Admin config is in four groups: **Slack account** (the location account's
With an **AbleSet** module installed and configured, the Plan tile also
follows the band: while AbleSet plays a song that is the item on screen,
that item's background fills as the song goes, and its sections slide past
in a strip at the bottom right of the item, the one playing filling in
turn. Both are switches in the tile's gear (**Song progress**, **Song
sections**), and both are simply absent when there is no AbleSet module, it
is switched off, or it has no host — nothing to turn off and nothing to
explain.
**Song Key** is one value, as large as the tile allows: the key of the item
ProPresenter has live, with the song's name and the next key under it — both
switches, as is the colour. When the feed that picks the item is gone the key
stays up, faded, and the tile's dot says so rather than pretending to be
current.
Keys are written as notes wherever the module shows one: Planning Center's
`Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the
Song Key tile alike.
**Teams** shows the same plan's team and position assignments — each team
as a band of its people, with their photo (fetched through ProdDash, never
by the browser; initials when Planning Center has none) and a dot for
confirmed, unconfirmed or declined. Its gear menu picks which teams and
which positions appear, groups people under their position or lists them
plainly, and sets the colours: one for the team headers, or **Tint teams**
to give each team its own, optionally carried into the rows. Teams nobody
has signed up for are hidden until you ask for them — that list comes from
the service type, since Planning Center only returns a team once somebody
is on it.
- **Slack** — live transcripts of this location's Slack channels. The +
picker lists one transcript tile per configured channel, titled
`#channel`. Messages arrive with avatars, names and times, Slack's inline
formatting and the workspace's custom emoji; text size and jump-to-latest
sit in the title bar. Sending is the **Send Message** module's: a click
anywhere in a transcript points its tiles at that channel (the bottom
edge lights up in the accent color), and a message then leaves through
this module's one Slack connection, as the location. A right-click on a
message opens the reaction menu, which rides on the same unlock — with
the page locked, or no Send message tile on it, it says so instead of
reacting. This module has no PIN, send tile or quick replies of its own.
Admin config is in three groups: **Slack account** (the location account's
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
or a bot token plus "Send as"), **Channels** (the joined channels to
follow, one transcript tile each), **Transcript** (refresh interval,
messages kept, thread replies) and **Sending** (the switch on the group's
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
follow, one transcript tile and one Send Message destination each) and
**Transcript** (refresh interval, messages kept, thread replies).
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.
- **Send Message** — the one place a dashboard sends from, whatever the
platform. Two tiles, one of each per dashboard: **Send message**, a
free-text box with the admin's quick emojis one press away, and **Quick
replies**, a grid of preset buttons. Both go wherever the operator last
pointed them with a click on a Slack or ProdCom transcript. A platform
whose destinations are identities rather than recipients — ProdCom, where
typed text goes out *as* a channel — also gets a **Send as** menu in the
title bar listing those identities; the menu exists only while such a
platform is installed, and Slack's channels are never in it. The choice is
saved with the tile, so a named layout remembers it; the transcript it
points at carries the accent stripe. The module
owns no connection of its own — it finds every installed module that
offers messaging and hands the message to it over loopback — and it owns
the PIN: nothing leaves the machine until it is entered on one of its
tiles, one entry unlocks every messaging tile on that dashboard page
(Slack's reaction menu included) until the auto-lock idles out, a reload
asks again, and the lock button ends it early. The PIN pad takes taps, a
keyboard, or a phone's — tapping the dots raises it. The PIN can be
switched off for a booth that doesn't need one.
Admin config: **PIN** (the switch on the group's title row, 4–8 digits,
auto-lock minutes) and **Presets** (quick replies one per line as
`Label | Message` — add `| slack:#channel` or `| prodcom:Channel` to bind
one to a destination — and up to eight quick emojis).
Tile settings: quick emojis on the Send message tile.
- **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.
To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`, including the
send route the Send Message module uses),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
@@ -422,7 +599,9 @@ ProdDash/
├── config/ proddash.json defaults (runtime state lives in the
│ per-machine data directory — see "Configure")
├── docs/MODULE-GUIDE.md how to build and install a module
├── docs/ALERTS.md the alerts feature: levels, channels, admin, API
├── tools/prodcom-mock.js mock ProdCom API for development
├── tools/alert.js raise / clear / list alerts from the command line
├── prodcom-listener/ reference fork — read-only, not served
└── band-lineup-display/ reference fork — read-only, not served
```
+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;
+132
View File
@@ -0,0 +1,132 @@
# ProdDash Alerts
A module that sees something a tech must act on *now* — a timer overrun,
a meter past its limit, timecode gone — raises an **alert**, and every
dashboard listening to its channel flashes until it is over. This page is
the whole feature: what an operator sees and sets, and what a module does
to raise one. The module-side contract also lives in the
[Module Guide](MODULE-GUIDE.md#raising-alerts), which is the authority on
how modules are written.
## What an alert looks like
On a dashboard that listens to the alert's channel:
- The **menu bar** flashes between two colours, with the alert's text in
bold across its middle (and a small boxed number saying which channel it
came on). If the bar was hidden — by its notch, or because the screen is
in fullscreen — it comes out for the alert and goes back when it ends.
- A **frame round the whole screen** flashes with it, so a fullscreen wall
where the bar is the only chrome still carries the flash to its edges.
- For **low** and **medium**, that is all: the tiles and the board they sit
on are left alone. A **critical** alert floods the **board behind the
tiles** as well — in a muted cut of its two colours, mixed well toward the
page background and showing faintly through the tiles' own panels — so
the flash reaches the middle of a screen the tiles cover entirely, while
everything written on a tile keeps its contrast. A module's content is
never recoloured.
The **level** sets the cadence. The two colours each level flashes between
are the admin's choice (below).
| Level | Cadence | For |
| --- | --- | --- |
| **Low** | a slow fade between the two colours, about three seconds a cycle; bar and frame only | something to know about |
| **Medium** | a faster fade, about one second a cycle; bar and frame only | something to deal with soon |
| **Critical** | no fade — the colours snap, twice a second; bar, frame and the board behind the tiles | something to deal with now |
When several alerts a dashboard listens to are on at once, the bar shows
the most severe (the newest among equals) and a **+1** says how many more
there are.
## Channels
There are **six channels**, numbered 1 to 6, and nothing else: a channel
carries no meaning of its own. A booth gives them meaning — "1 is the
stage, 2 is video, 3 is the lobby" — by pointing modules at them (a module
that raises alerts makes its channel an admin setting) and by choosing
which channels each dashboard listens to.
**Choosing channels:** on the dashboard, open the Layouts menu (the grid
icon) and the **Alerts** row — it says which channels this screen listens
to and opens to six numbered buttons that toggle them. The choice is part
of the arrangement, like the grid size and auto-hide: it saves with this
browser's layout and with a named layout on the server, so a booth that
loads its "Stage" layout gets the stage's channels with it. A fresh
dashboard listens to nothing.
## One alert per channel
A channel shows one alert at a time. A new alert on a channel replaces
whatever was on it, whoever raised it; clearing a channel ends whatever is
on it. Two modules pointed at one channel will talk over each other — give
them channels of their own.
An alert ends when its **duration** runs out (1 to 600 seconds; 15 when the
module gives none), or when the module that raised it clears it early, or
when that module stops (a disable, an uninstall, a config save that re-inits
it): a module's alerts never outlive it. A module may hold an alert **until
cleared** (duration 0) for a condition it will notice ending — timecode
lost, a feed down — and clear it when the condition does.
## Admin → Alerts
The admin page's **Alerts** section (folded by default, near the bottom)
holds two colour pickers per level — the two colours that level flashes
between — with a swatch beside each row running that level's cadence, so a
pair can be judged before it is tried. A change saves when the picker
closes and reaches every open dashboard at once. The row below sends a
**test alert** of any level on any channel: eight seconds of whatever the
dashboards listening to that channel will see.
The colours are kept as `alerts.colors` in the data directory's
`proddash.json` (see the README's *Where settings are kept*); they hold
across restarts and updates. The defaults are a blue pair for low, amber
for medium and red against near-black for critical.
## For module authors
Raise from the server entry, which is where a condition is usually noticed
— `shell.alert({ channel, level, text, duration })` and
`shell.clearAlert(channel)` on the `shell` object `init()`, `routes()` and
`tiles()` receive — or from a tile with `moduleApi.alert(...)` and
`moduleApi.clearAlert(...)`, which go through the server. The fields, what
each is for, and the rules that keep alerts worth looking at (raise on the
transition, make it a setting, it is not a status) are in the
[Module Guide](MODULE-GUIDE.md#raising-alerts).
## The HTTP API
Everything above rides on three public routes and two admin ones. They are
open to anything that can reach ProdDash, like a module's own routes:
an alert is a flash on screens in this building and nothing more.
| Route | Does |
| --- | --- |
| `GET /api/alerts` | `{ channels: 6, levels, colors: { low: [a, b], … }, active: [ … ] }` — what is flashing and the colours. Dashboards read it on load and whenever their stream reconnects. |
| `POST /api/alerts` | `{ channel, level, text, duration?, source? }` as `application/json`. Answers `200 { ok, alert }` or `400 { error }` saying what was wrong. `source` is the module id (anything else is recorded as `api`). |
| `DELETE /api/alerts/<channel>` | Ends the alert on that channel. `{ ok, cleared }` — `cleared` is false when nothing was on it. |
| `POST /api/admin/alerts` | `{ colors: { low: [a, b], medium: [a, b], critical: [a, b] } }` — a missing or malformed colour takes its default. Behind the admin PIN. |
| `POST /api/admin/alerts/test` | `{ level, channel }` — an eight-second test alert. Behind the admin PIN. |
Open dashboards learn of changes over the shell stream (`/api/events`):
`alert` carries an alert as raised (`{ id, channel, level, text, source,
startedAt, duration, endsAt, remainingMs }` — a browser counts down from
`remainingMs`, so the clocks need not agree), `alert-clear` carries
`{ channel, id }` when one ends, and `alerts-config` carries `{ colors }`
when the admin changes them.
## Trying it
With ProdDash running, from the repo:
```bash
node tools/alert.js -c 1 -l critical "LTC lost"
```
raises a critical alert on channel 1 for the default 15 seconds; `-d 0`
holds it, `node tools/alert.js -c 1 --clear` ends it, `--list` shows what
is on. Open a dashboard, choose channel 1 in its Layouts menu → Alerts, and
watch; a second browser listening to channel 2 should stay quiet. The admin
page's **Test** buttons do the same from the browser.
`node tools/alerts.test.js` runs the server's side of all this end to end.
+298 -16
View File
@@ -28,7 +28,10 @@ people running the booth depend on them.
ProdDash. If your feature needs a listener, a decoder or a worker, your
server entry owns it: start it in `init()`, stop it in `stop()`, report
it in `health()`. A child process the module itself spawns and supervises
is fine; a program someone launches by hand is not.
is fine; a program someone launches by hand is not. **A native helper —
anything that is not JavaScript — ships prebuilt inside the module folder,
for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on
the machine running ProdDash** (see "Native helpers" below).
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
manifest) and **bump your own `version`** whenever you change the module.
The admin page refuses to install a module whose requirement the running
@@ -83,19 +86,86 @@ modules/<id>/
├── module.json manifest (required)
├── client.js ES module, client entry (required)
├── server.js server entry (optional — only if you need server code)
├── bin/ native helpers, prebuilt per platform (only if you need one)
│ ├── <name>-macos-arm64
│ └── <name>-win-x64.exe
├── native/ their source and the script that builds every bin/ file
└── ... assets (css, images…) served statically at /modules/<id>/
```
**Installation is exactly this:** in the admin page, open **Available
modules** and press **Install** — the module is downloaded from the ProdDash
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
immediately. Developing locally? Drop the folder into `modules/` (bundled
with the checkout) or into the data directory's `modules/` and restart. No
shell code changes, ever. Modules bundled with a checkout and modules
installed from the admin page live side by side; when both have the same id
the newer version runs. **Uninstall** in the admin page removes an installed
module's files (a bundled one is hidden instead, since its folder belongs to
the app); its settings are kept for a later reinstall.
immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
with **no modules at all**: every one of them arrives this way, and the
shell's own update never brings one along, so what is in git is exactly what
runs on a booth. Developing locally? A git checkout runs whatever is in its
own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
the data directory's `modules/` and restart. No shell code changes, ever. In
a checkout the two live side by side; when both have the same id the newer
version runs. **Uninstall** in the admin page removes an installed module's
files (in a checkout, the tree's copy is hidden instead, since that folder
belongs to the repo); its settings are kept for a later reinstall.
### Native helpers
Some features need a program JavaScript cannot be: reading an audio input,
decoding timecode, measuring sound. Rule 3 says the module owns that program;
this section says how it ships: **already built, for every platform it runs
on, inside the module folder.** The machine running ProdDash is a packaged
app or the Windows bundle plus modules from the admin page's catalog. It has
no compiler and never will — the catalog delivers exactly the folder that is
in the repo, so the binaries live in git next to the code.
- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel
macOS is welcome but optional. One file per platform in `bin/`, named for
what it is:
| Platform | File |
| --- | --- |
| macOS, Apple silicon | `bin/<name>-macos-arm64` |
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
| macOS, Intel (optional) | `bin/<name>-macos-x86_64` |
- **Pick it at runtime, and cope without it.** Choose by `process.platform`
and `process.arch`; when there is no file for this machine, the feature
says so — in `health()` and in the tile's `setStatus` — and everything else
in the module keeps working. Never try to build it, never look for a copy
elsewhere on the machine, never tell the operator to install anything.
```js
const HELPERS = {
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
win32: { x64: 'ltc-capture-win-x64.exe' },
};
const name = HELPERS[process.platform]?.[process.arch];
const helper = name ? path.join(__dirname, 'bin', name) : '';
if (!helper || !fs.existsSync(helper)) {
// Timecode is off on this platform. Say it once, keep the clock running.
}
```
- **One contract, any language.** Every platform's binary speaks the same
command line and output format, so `server.js` has one code path. What is
behind it is your choice per platform — Swift against CoreAudio on macOS,
C#, C or Rust on Windows — the module does not care.
- **Source and build beside it.** Keep the source under `native/` with one
script that produces every `bin/` file from a checkout, so any binary can be
rebuilt from what is committed. A helper that cannot be rebuilt does not
ship.
- **Bump the module version whenever a binary changes**, like any other
file — that is how installs learn about it.
- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at
most: the catalog fetches the whole repo as one archive, capped at 80 MB.
- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary
— Developer ID when you have it, ad-hoc at least. Windows needs nothing:
files the app writes carry no download mark, so SmartScreen never sees
them. Permissions come from the launcher (see "Permissions macOS has to
grant"), not from the binary.
A helper that ends up existing for one platform only is an exception made
on purpose, not a default: say so in the module's README and in the health
line, so a Windows booth knows why the feature is off.
## module.json
@@ -132,7 +202,7 @@ the app); its settings are kept for a later reinstall.
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
| `description` | no | Shown in the admin page / picker. |
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
| `provides` | no | Capabilities other modules can consume — `["timers"]`, `["messaging"]`. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
| `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. |
@@ -152,7 +222,9 @@ Both schemas map field names to specs:
"help": "One line shown under the control", // optional, admin config only
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
"optionsRoute": "/devices", // "select" only, admin config only
"optionsDependsOn": "<other key>" // with optionsRoute; see below
"optionsDependsOn": "<other key>", // with optionsRoute; see below
"orderable": true, // "multiselect", admin config only
"addLabel": "Add a service type…" // with "orderable"; the dropdown's own line
}
```
@@ -172,8 +244,22 @@ marked, so nothing is dropped behind anyone's back. In a tile's gear popover
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
chosen shows every option on and saves nothing until someone flips one, so
options that turn up later are on as well (give it no `default` then).
A `multiselect` with **`orderable: true`** stops being a ticklist. It renders
as a list built on purpose — the picks, numbered in their saved order, each
with a nudge either way and a remove — over a dropdown (`addLabel` names it)
holding everything not picked yet, `group` and all. The array is stored in
that order, so a module can read it as a priority. It is for a setting whose
ORDER means something, and it is also what to reach for when the list is long:
an account with forty service types is forty switches to read past, but one
dropdown and two picked rows. `pco-plan`'s service types are the worked
example — several are followed, and when two of them have a plan at the same
moment the one higher up wins. Admin config only; the tile gear's own
multiselect ignores it.
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
`optionsDependsOn` follows any field, not just a select: a multiselect hands
its values over comma-joined.
`template` (tile settings) is a text with placeholders, built by dragging or
clicking the variables the module offers into it: `"variables": [ { "value":
@@ -367,6 +453,8 @@ export default function create({ root, moduleApi }) {
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
| `alert(opts)` | Flash every dashboard listening to an alert channel — `{ channel, level, text, duration }`, see **Raising alerts**. Returns a promise. A module with a server entry raises from there (`shell.alert`) instead. |
| `clearAlert(channel)` | End the alert on a channel early. Returns a promise. |
| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). |
| `header.addMenu(…)` | Put a dropdown menu button there (see below). |
@@ -391,7 +479,8 @@ const menu = moduleApi.header.addMenu({
build(menuEl) {
// called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and
// fixed-positioned by the shell, so the tile's overflow can't clip it)
// positioned by the shell, hosted on the body under an element carrying
// your data-module, so your scoped CSS applies and nothing clips it)
},
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
@@ -529,6 +618,9 @@ module.exports = {
- `shell.port` is the port ProdDash is serving on — what you need to read
another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
modules/<id>/…`). Never guess it.
- `shell.alert(opts)` flashes every dashboard listening to an alert channel
and `shell.clearAlert(channel)` ends one early — see **Raising alerts**.
Alerts your module raised end when it stops.
- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
the matched remainder (leading `/` included) and `req.search` is the query
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
@@ -596,8 +688,11 @@ module.exports = {
tile added from an entry only when that entry (or one of its `aliases`) is
in your list right now; otherwise the tile keeps its place in the layout,
draws nothing, and appears the moment your list offers it again — a
Service timer waits for PCO Plan, a transcript for its channel. So your
client is never asked to render an entry you did not offer at mount time.
Service timer waits for PCO Plan, a transcript for its channel. The
operator can remove such a tile from the Layouts menu's **Not shown**
section, so an entry you retire for good (a send box that moved to another
module) leaves no dead cells behind. So your client is never asked to
render an entry you did not offer at mount time.
One case is still yours: an entry that vanishes *after* the tile mounted
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
placeholder, no error — and the shell folds the tile away at its next
@@ -633,6 +728,72 @@ per-client `res.write` in try/catch (drop the client on failure). The
`propresenter-now-next` module is the reference implementation of this
pattern.
## Raising alerts
An alert is for the thing a tech must act on *now*, whichever tile they
happen to be looking at — a timer that has overrun, a meter past its limit,
timecode that has gone quiet mid-service. Every dashboard that listens to
the alert's channel flashes: its menu bar (brought out for the duration,
even if it was hidden) and a frame round the whole screen alternate between
the level's two colours, with your text bold across the middle of the bar.
The shell owns all of that — the colours (Admin → Alerts), the cadence,
which screens listen (each dashboard's Layouts menu → Alerts) — so a module
only has to say what happened. [docs/ALERTS.md](ALERTS.md) has the
operator's side.
Raise one from your server entry, where the condition is usually noticed:
```js
init({ config, log, shell }) {
// …when timecode drops:
shell.alert({ channel: config.alertChannel, level: 'critical', text: 'LTC lost', duration: 0 });
// …and when it is back:
shell.clearAlert(config.alertChannel);
}
```
or from a tile, through the shell's server:
```js
await moduleApi.alert({ channel: 2, level: 'medium', text: 'Service over by 5:00', duration: 20 });
```
| Field | Meaning |
| --- | --- |
| `channel` | `1`–`6`. Which dashboards flash: each listens to the channels its operator chose in the Layouts menu. A channel carries no meaning of its own — the booth decides that "1 is the stage, 2 is video" — so **make yours an admin setting** (`configSchema`, `type: "number"`, `min: 1`, `max: 6`) rather than hard-coding one. |
| `level` | `low` — a slow fade between the two colours, for something to know about; `medium` — a quicker pulse, for something to deal with soon; `critical` — no fade, the colours snap, and the board behind the tiles floods with a muted cut of them too, for something to deal with now. |
| `text` | What happened, in a few words — 80 characters at most (more is cut, whitespace is collapsed). It is shown as given, so write it the way it should read on a wall: `SPL 102 dB — over limit`, not `spl_over_limit`. |
| `duration` | Seconds, 1 to 600; `15` when omitted. `0` holds the alert until you clear it — for a condition you will *notice ending* (timecode lost, a feed down) and clear; a one-off event takes a duration. |
Both return the alert as raised (`{ id, channel, level, text, source,
startedAt, duration, endsAt, remainingMs }`) and throw — reject, on the
client — with a one-line reason when the input is refused. That is a bug
in your module, not something to show the operator.
What to know:
- **One alert per channel.** A new alert on a channel replaces whatever was
on it, whoever raised it; `clearAlert(channel)` ends whatever is on it.
Two modules on one channel talk over each other — one more reason the
channel is admin config.
- **Your alerts end when you stop.** A disable, an uninstall or a config
save that re-inits you clears every alert your module raised, held ones
included. If the condition is still true, raise it again from `init()`.
- **Raise on the transition, not the state.** `if (over && !wasOver)
shell.alert(…)` — an alert re-raised on every poll restarts its timer and
flashes for ever. Clear it, or let it expire, when the condition ends.
- **Make alerting a setting**, and the threshold too: a `switch` in
`configSchema`, off by default unless alerting is the module's reason to
exist. A screen that flashes for nothing teaches a room to ignore it.
- **It is not a status.** The tile's dot (`setStatus`) says how *you* are;
an alert says what the *room* must do. An unreachable upstream turns the
dot red; it raises an alert only if that is something a tech has to act on
during the service, and the operator switched that on.
- Alerts reach the server over `POST /api/alerts` — what `moduleApi.alert`
calls, with your module id as `source` — which anything on the network may
use too; `node tools/alert.js -c 1 -l critical "LTC lost"` raises one from
the command line while you build (`--clear`, `--list`).
## Working with other modules
Modules don't import each other. When one needs what another knows, it reads
@@ -684,6 +845,7 @@ clock, and any module that offers some. A module offers timers by:
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
| `detail` | Optional second line. |
@@ -698,6 +860,108 @@ comment every 15 s); the consumer uses the stream when it exists and polls
wall — the Timers module lets them hide any of them per tile, but every entry
you add is a decision someone has to make.
### Offering messaging to the Send Message module
The Send Message module is the one place a dashboard sends from — a
PIN-gated compose box and a grid of quick replies — whatever the platform.
It owns nothing upstream: a module that *can* deliver a message (Slack posts
to a channel, ProdCom puts typed text on a comms channel) offers that over
its own routes, keeps its one upstream connection, and the Send Message
module hands messages to it over loopback. A provider has no PIN, no send
tile and no quick replies of its own; the shell's Add-tile picker shows the
Send message and Quick replies tiles once, under Send Message.
A module offers messaging by:
1. Declaring it — `"provides": ["messaging"]` in `module.json`. The Send
Message module re-reads `/api/modules` every 30 s, so a provider installed
mid-session shows up without a restart.
2. Serving `GET /messaging` — what it can send to, from state it already
holds (it is polled every few seconds while a send tile is open):
```json
{
"label": "Slack",
"identity": "Apollo Beach",
"destinationRole": "recipient",
"status": { "state": "ok", "message": "Apollo Beach · #production, #ops" },
"destinations": [
{ "id": "C01ABC", "label": "#production" },
{ "id": "C02DEF", "label": "#ops", "color": "#5c9de0" }
],
"emptyMessage": "",
"maxChars": 4000
}
```
| Field | Meaning |
| --- | --- |
| `label` | The platform's name, as the sending line and the Send as menu show it. |
| `identity` | Who a message appears to come from when that is fixed by the connection, or `''`. Shown as "as Apollo Beach". |
| `destinationRole` | What a destination *is*. `recipient` (the default): a message goes **to** it — a Slack channel — and the operator picks one by clicking its transcript tile, so it never appears in the Send as menu. `identity`: a message goes out **as** it — a ProdCom channel — and the Send as menu lists them; the tiles then say "Send message to ProdCom as FOH" and "Sent to ProdCom as FOH". The menu exists only while some installed platform offers identities. |
| `status` | `ok`, `connecting` or `error` plus one line — the same thing your `health()` says. A provider in error is still listed, so the operator sees why a send will fail before trying. |
| `destinations` | Where a message can go, or who it can go out as: a stable `id` (what `/messaging/send` receives), a `label`, and an optional `color`. Offer only what will work — ProdCom lists just the channels that accept typed text. Order is the order the menu shows. |
| `emptyMessage` | Optional: why the list is empty, when you know ("No ProdCom channel accepts typed text — turn on typing for one in ProdCom"). The tiles show it in place of a destination. |
| `maxChars` | The longest text you accept; the Send Message module refuses longer ones before asking you. |
3. Serving `POST /messaging/send` — `{ "destination": "<id>", "text": "…" }`
with the browser's unlock token as `Authorization: Bearer …`. **Check the
token before touching your upstream**: the Send Message module owns the
PIN, so ask it — `GET http://127.0.0.1:${shell.port}/api/modules/send-message/verify`
with the same `Authorization` header answers `200 { ok: true, expiresAt }`
or `401`. A `404` means no Send Message module is installed or enabled:
refuse with a sentence that says so. Without this check, any browser on
the network could post as the location by calling your route directly.
Answer `200 { "ok": true, "label": "#ops" }` when it landed, otherwise
`{ "error": "<one sentence a tech can act on>", "code": "…" }` with `400`
(bad destination or text), `401` (the token was refused), `429` (your
upstream is rate-limiting) or `502` (your upstream failed). Validate the
destination against your own list — never pass an arbitrary id upstream.
The same verification gates any other outbound action a module offers from
its tiles — the Slack transcript's emoji reactions ride on it — so a module
never needs a PIN of its own: one unlock serves every messaging tile on the
page. The `slack` and `prodcom-transcript` modules are the reference
implementations (`verifyUnlock()` in either server entry is the whole check).
### Messaging on the page
Where the next message goes is a fact about the whole dashboard, not one
tile: the Send message tile, the Quick replies tile and every transcript on
the page must agree, and they belong to different modules. Modules don't
import each other, so the agreement lives on `window` — one plain object plus
`CustomEvent`s on `window`, created by whichever messaging-aware tile mounts
first:
```js
window.proddashMessaging = {
version: 1,
target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' },
unlock: { token: '', expiresAt: 0, reason: '' },
quickEmojis: [], // the Send Message module's admin list, plain strings
transcripts: new Map(), // instanceId → { provider, destination }
senders: new Set(), // instanceIds of Send message / Quick replies tiles
};
```
Events, all named `proddash:messaging:<name>` with the payload in `detail`:
| Event | Who sends it | Meaning |
| --- | --- | --- |
| `select` | a transcript tile, or a Send as menu | `{ provider, destination }` — the operator pointed the senders here. A tile bound to one recipient (a Slack channel transcript) names it; one that shows a whole platform (ProdCom) sends `destination: ''` and the Send Message tile keeps the identity it has chosen for that platform. |
| `target` | the Send Message tiles | `window.proddashMessaging.target` changed: where the next message goes, resolved. A transcript compares `provider` (and `destination`, when it has one) and lights its bottom edge. |
| `unlock` | the Send Message tiles; a provider's tile on a `401` | `window.proddashMessaging.unlock` changed. Read the token from the object, never cache it; `''` means locked, with `reason` saying why. |
| `transcripts` | transcript tiles | the `transcripts` map changed (a tile mounted, unmounted or learned its channel). Register once you know your destination; unregister in `stop()`. This is how a dashboard with one transcript sends there by default. |
| `senders` | the Send Message tiles | the `senders` set changed — a transcript's locked note can say "add a Send message tile" rather than "enter the PIN". |
A transcript tile needs only three things: emit `select` on a click in its
body, register in `transcripts`, and toggle a target class on `target`. A
tile that performs a gated action itself (a reaction) reads
`window.proddashMessaging.unlock.token` and sends it as the Bearer token to
its own module's route. The Send Message tiles save the chosen platform and
per-platform destination in their instance settings, so a named layout
remembers them; the page object is the live copy.
## Complete minimal example
A working clock — copy-paste this as your starter. (A slightly fancier
@@ -783,16 +1047,29 @@ client-side failures (a tile that fails to start says so in the tile).
**Poke the pieces directly:**
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
- `GET /api/modules` — what the shell sees (your manifest + non-secret
config; `inactive` names the installed modules that are not running).
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
`curl -N http://localhost:24500/api/modules/<id>/stream`.
- `GET /api/admin/state` — enabled flags, health, mount errors.
- `GET /api/alerts` — what is flashing, and the colours. `node tools/alert.js
-c 1 -l critical "LTC lost"` raises one without a module (`--clear`,
`--list`); a dashboard shows it once channel 1 is chosen in its Layouts
menu → Alerts.
**Mock upstreams** (so you can develop without the real gear):
- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups,
history, and a live stream with in-progress speech. Point the
transcript module's ProdCom URL at `http://127.0.0.1:24480`.
history, a live stream with in-progress speech, and
`POST /api/v1/channels/{id}/send`, which lands typed text in the channel
as a `source: "typed"` entry (so a send from the Send Message module can
be watched arriving; a text containing `!429` answers RATE_LIMITED, and
`PRODCOM_MOCK_KEY=…` makes it demand that API key). Point the transcript
module's ProdCom endpoint at `127.0.0.1`, port `24480`.
- Send Message: no mock — it talks to the installed modules. Run the Slack
and ProdCom mocks, set a PIN under Send Message in `/admin` (or switch the
PIN off), add a transcript or two plus Send message and Quick replies from
+, and watch a send arrive in the transcript you clicked.
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
and an advancing slide index (including the arrangement-repeat case).
Point the Now/Next module at host `127.0.0.1`, port `1599`.
@@ -811,6 +1088,11 @@ client-side failures (a tile that fails to start says so in the tile).
Application ID + Secret under PCO Plan in `/admin`, save, then pick
"Sunday Service" from the Service type list. Its item titles match
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through
over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a
section that loops twice, a song queued out of order, heartbeats.
`--stopped`, `--no-live` and `--speed 4` exercise the other states. Point
the AbleSet module at host `127.0.0.1`, port `39071`.
**The resilience checklist** — every module must pass this before it's done:
+5 -3
View File
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
```bash
./build.sh # build/ProdDash.app — the whole application
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
./build.sh --install # …and copy it to /Applications
./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
@@ -68,9 +69,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
against nodejs.org's published checksums either way.
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon
is the app's icon. A module's native helper is compiled during the build
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
ProdDash never needs a compiler.
is the app's icon. The app carries no modules: they are installed from the
admin page's catalog into the data directory, and a module's native helper
ships prebuilt inside it (see the module guide), so the Mac running ProdDash
never needs a compiler.
## Signing
+15 -25
View File
@@ -1,6 +1,8 @@
#!/bin/zsh
# Build ProdDash.app — the whole application: launcher, server and its own
# Node runtime, with nothing to install first.
# Node runtime, with nothing to install first. It carries no modules: those
# come from the admin page's catalog into the data directory, and a module's
# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md).
#
# ./build.sh build/ProdDash.app, self-contained
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
@@ -11,6 +13,8 @@
# ./build.sh --dev launcher only — no Node, no server payload, so
# it runs against the checkout. Seconds, not
# minutes, while working on the launcher itself.
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
# silicon); the Node it embeds follows.
#
# Signing: a "Developer ID Application" certificate is used when one is
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
@@ -27,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
# The Node the app ships. Bump it here; the download is checksum-verified
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
NODE_VERSION="v24.21.0"
ARCH="arm64"
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
DEPLOYMENT_TARGET="13.0"
case "$ARCH" in
arm64) NODE_ARCH="arm64" ;;
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
esac
REPO_ROOT="../.."
OUT_DIR="build"
@@ -106,7 +115,7 @@ fi
# ── the server, and the Node that runs it ────────────────────────────────
if [[ $DEV -eq 0 ]]; then
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
@@ -125,25 +134,16 @@ if [[ $DEV -eq 0 ]]; then
echo " embedding Node $NODE_VERSION"
mkdir -p "$CONTENTS/Resources/node/bin"
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
echo " embedding ProdDash $VERSION"
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
mkdir -p "$PAYLOAD/config"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
# Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
find "$PAYLOAD" -name '.DS_Store' -delete
# A module's native helper is built here, so the Mac running ProdDash needs
# no compiler: <name>.swift beside a module's files becomes <name>.
for source in "$PAYLOAD"/modules/*/*.swift; do
[[ -e "$source" ]] || continue
helper="${source%.swift}"
echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")"
swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source"
done
fi
# ── signing ──────────────────────────────────────────────────────────────
@@ -165,16 +165,6 @@ fi
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
fi
if [[ $DEV -eq 0 ]]; then
for helper in "$PAYLOAD"/modules/*/*; do
# A compiled helper is the extensionless executable beside a module's
# sources — test the name, not the path, which has dots all over it.
name=$(basename "$helper")
[[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue
echo " signing $name"
codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper"
done
fi
codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
codesign --verify --deep --strict "$APP" && echo " signature verifies"
+121
View File
@@ -0,0 +1,121 @@
#!/bin/zsh
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
# its own Node runtime and a start script, so nothing has to be installed on
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
#
# ./build.sh build/ProdDash-<version>-win-x64.zip
#
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
# the server running and starts it again when the admin page applies an
# update (the server exits with code 75 to ask for that).
set -e -u
cd "$(dirname "$0")"
APP_NAME="ProdDash"
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
NODE_VERSION="v24.21.0"
NODE_DIST="node-$NODE_VERSION-win-x64"
REPO_ROOT="../.."
OUT_DIR="build"
CACHE_DIR=".cache"
for tool in curl shasum unzip zip; do
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
done
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
NAME="$APP_NAME-$VERSION-win-x64"
STAGE="$OUT_DIR/$NAME"
echo "ProdDash $VERSION — building the Windows bundle"
rm -rf "$OUT_DIR"
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
# ── Node for Windows ─────────────────────────────────────────────────────
ZIPFILE="$NODE_DIST.zip"
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
fi
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
# This binary ships under ProdDash's name — check it every build.
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
exit 1
fi
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
# ── ProdDash ─────────────────────────────────────────────────────────────
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
cp -R "$REPO_ROOT/public" "$STAGE/"
# Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
find "$STAGE" -name '.DS_Store' -delete
# ── the start script and the read-me, with Windows line endings ──────────
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
crlf "$STAGE/ProdDash.bat" <<'BAT'
@echo off
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
title ProdDash
cd /d "%~dp0"
rem The server exits with code 75 after applying an update from the admin page; start it again.
set PRODDASH_LAUNCHER=1
:run
"%~dp0node\node.exe" server.js
set CODE=%ERRORLEVEL%
if %CODE% EQU 75 goto run
echo.
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
pause >nul
BAT
crlf "$STAGE/README.txt" <<README
ProdDash $VERSION for Windows (64-bit)
=======================================
Nothing to install. This folder holds the dashboard and its own copy of Node.
1. Move this folder somewhere you can write to, for example C:\ProdDash
(not Program Files: updates write into this folder).
2. Double-click ProdDash.bat. A window opens and stays open while the
dashboard runs; closing it stops the dashboard.
- If Windows warns about the file, choose More info, then Run anyway.
- When Windows Defender Firewall asks about node.exe, allow it on
private networks so other screens can reach the dashboard.
3. Open http://localhost:24500 on this PC. Other devices on the network use
http://<this PC's IP address>:24500. Settings are on the admin page,
http://localhost:24500/admin.
4. The bundle ships without modules. On the admin page, tick the ones this
PC needs under Available modules and press Install selected; each one
updates on its own from then on.
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
so unzipping a new version or updating never loses them.
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
shortcut to ProdDash.bat in the folder that opens.
Updates: the admin page's Update now fetches the new version into this
folder and restarts the dashboard; the window stays open. A copy of the
version it replaced is kept for a short while in case the new one will not
start.
README
# ── zip ──────────────────────────────────────────────────────────────────
echo " zipping"
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
+727
View File
@@ -0,0 +1,727 @@
/* AbleSet — client part.
One factory draws every AbleSet tile; `moduleApi.variant` says which one
(the server's tiles() list). All of them read the same stream: `state`
whenever something changes, `pos` five times a second while playing.
Between packets the playhead and the countdowns are extrapolated from the
tempo and the clock, so bars glide instead of stepping. */
const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status'];
/* AbleSet names its colours after Live's palette; these are close enough to
read as the same colour on a dark tile. Unknown names draw no colour. */
const NAMED_COLORS = {
red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399',
mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6',
indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6',
rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5',
};
const ICON_STRIP = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="5" width="4" height="6" rx="1"/><rect x="6.5" y="4" width="4" height="8" rx="1"/><rect x="11.5" y="5" width="3" height="6" rx="1"/></svg>';
const ICON_WHEEL = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h8M3 6h10M3 10h10M4 13.5h8"/><rect x="2" y="5" width="12" height="6" rx="1.2"/></svg>';
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/** 83 → "01:23", 3725 → "1:02:05". Whole seconds round down, as AbleSet's own display does. */
function fmt(sec) {
if (sec == null || !Number.isFinite(sec)) return '--:--';
const s = Math.max(0, Math.floor(sec + 1e-6));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${String(m).padStart(2, '0')}:${ss}`;
}
function fmtBpm(t) {
if (t == null) return '--';
return (Math.round(t * 100) / 100).toString();
}
function colorFor(value) {
if (!value) return null;
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
return NAMED_COLORS[String(value).toLowerCase()] || null;
}
export default function create({ root, moduleApi }) {
const view = VIEWS.includes(moduleApi.variant) ? moduleApi.variant
: moduleApi.variant ? '' : 'now'; // an entry that vanished draws nothing
const prefs = () => moduleApi.instanceSettings || {};
const on = (k) => prefs()[k] !== false;
let state = null;
let pos = { beats: null, remainingSong: null, remainingSet: null };
let posAt = 0;
let stream = null;
let raf = 0;
let resizeObserver = null;
let tickTimer = null;
/* ── moving values, extrapolated to this frame ─────────────────── */
const moving = () => Boolean(state && state.reachable && state.playing);
const dt = () => (moving() ? (performance.now() - posAt) / 1000 : 0);
function beatsNow() {
if (pos.beats == null) return null;
const t = state?.tempo;
return t ? pos.beats + dt() * (t / 60) : pos.beats;
}
const songLeft = () => (pos.remainingSong == null ? null : Math.max(0, pos.remainingSong - dt()));
const setLeft = () => (pos.remainingSet == null ? null : Math.max(0, pos.remainingSet - dt()));
function fraction(start, end, b) {
if (start == null || end == null || b == null || end <= start) return null;
return Math.min(1, Math.max(0, (b - start) / (end - start)));
}
function songsLeft() {
if (!state?.song || state.song.index == null) return null;
return Math.max(0, state.setlist.songs.length - state.song.index - 1);
}
/** Beats per bar from the time signature (Live counts quarter notes). */
function beatsPerBar() {
const ts = state?.timeSig;
return ts && ts[0] > 0 && ts[1] > 0 ? (ts[0] * 4) / ts[1] : 4;
}
/** Bar and beat within the song, 1-based. */
function barBeat() {
const b = beatsNow();
const start = state?.song?.start ?? 0;
if (b == null) return null;
const rel = Math.max(0, b - start);
const bpb = beatsPerBar();
const ts = state?.timeSig || [4, 4];
const beatLen = 4 / (ts[1] || 4);
const bar = Math.floor(rel / bpb) + 1;
const beat = Math.floor((rel % bpb) / beatLen + 1e-6) + 1;
return { bar, beat, of: ts[0] || 4 };
}
function tone(sec) {
if (sec == null || !moving()) return '';
const warn = Number(prefs().warnAt ?? 30);
const alert = Number(prefs().alertAt ?? 10);
if (sec <= alert) return 'alert';
if (sec <= warn) return 'warn';
return '';
}
/* ── fitting one-line text to its box ──────────────────────────── */
/** Shrink a nowrap element until it fits the width its parent gives it. */
function fit(el) {
if (!el || !el.isConnected) return;
el.style.fontSize = '';
const max = parseFloat(getComputedStyle(el).fontSize) || 16;
const avail = el.parentElement ? el.parentElement.clientWidth : el.clientWidth;
let size = max;
for (let i = 0; i < 3 && el.scrollWidth > avail + 0.5 && size > 9; i += 1) {
size = Math.max(9, size * (avail / el.scrollWidth) * 0.98);
el.style.fontSize = `${size}px`;
}
}
const fitAll = () => root.querySelectorAll('[data-fit]').forEach(fit);
/** Set text, refitting only when it changed. */
function text(el, value) {
if (!el) return;
const v = String(value ?? '');
if (el.textContent === v) return;
el.textContent = v;
if (el.hasAttribute('data-fit')) fit(el);
}
function show(el, visible) {
if (el) el.hidden = !visible;
}
/* ── skeleton per view ─────────────────────────────────────────── */
root.innerHTML = `<div class="as-root as-${view || 'none'}"><div class="as-offline" hidden></div><div class="as-body"></div></div>`;
const shell = root.firstElementChild;
const body = shell.querySelector('.as-body');
const offlineEl = shell.querySelector('.as-offline');
const $ = (sel) => body.querySelector(sel);
const BAR = `<div class="as-bar"><div class="as-bar-done"></div><div class="as-bar-sect"><div class="as-bar-played"></div></div></div>`;
const SKELETONS = {
now: `
<div class="as-title-box"><div class="as-title" data-fit></div></div>
<div class="as-badges">
<span class="as-badge b-tempo"></span><span class="as-badge b-meter"></span><span class="as-badge b-dur"></span><span class="as-badge b-clock"></span><span class="as-badge b-section"></span>
</div>
${BAR}
<div class="as-next">
<div class="as-next-label">Next:</div>
<div class="as-next-box"><div class="as-next-title" data-fit></div></div>
<div class="as-left"></div>
</div>`,
sections: `
<div class="as-song-name" data-fit></div>
<div class="as-viewport"><div class="as-track"></div></div>`,
setlist: `
<div class="as-set-head"><span class="as-set-name"></span></div>
<div class="as-list"></div>
<div class="as-set-foot"></div>`,
transport: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-pos"></span><span class="as-loop"></span></div>`,
tempo: `
<div class="as-big-box"><div class="as-big" data-fit><span class="as-bpm"></span><small> BPM</small></div></div>
<div class="as-sub"><span class="as-meter"></span></div>
<div class="as-beats"></div>`,
'song-time': `
<div class="as-caption" data-fit></div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub as-sub-time"></div>
${BAR}`,
'set-time': `
<div class="as-caption" data-fit>Set</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-left-count"></span><span class="as-end"></span></div>`,
section: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-sect"></span><span class="as-count"></span></div>
<div class="as-sect-bar"><div></div></div>`,
'next-song': `
<div class="as-caption">Next</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-dur"></span></div>
<div class="as-queued" hidden></div>`,
status: `<div class="as-rows"></div>`,
};
body.innerHTML = SKELETONS[view] || '';
/* ── shared pieces ─────────────────────────────────────────────── */
/** The three-part song bar: played sections, this section, the playhead. */
function paintBar(bar) {
if (!bar) return;
const s = state?.song;
const sect = state?.section;
const b = beatsNow();
const len = s && s.end > s.start ? s.end - s.start : 0;
const at = (v) => (len ? Math.min(100, Math.max(0, ((v - s.start) / len) * 100)) : 0);
const sStart = sect && sect.start != null ? at(sect.start) : 0;
const sEnd = sect && sect.end != null ? at(sect.end) : 0;
const head = b != null ? at(b) : 0;
bar.style.setProperty('--done', `${sStart}%`);
bar.style.setProperty('--sect-l', `${sStart}%`);
bar.style.setProperty('--sect-w', `${Math.max(0, sEnd - sStart)}%`);
const inSect = sEnd > sStart ? Math.min(100, Math.max(0, ((head - sStart) / (sEnd - sStart)) * 100)) : 0;
bar.style.setProperty('--played', `${inSect}%`);
}
function songTint(el, hex) {
if (hex) el.style.setProperty('--song', hex);
else el.style.removeProperty('--song');
el.classList.toggle('has-song-color', Boolean(hex));
}
/* ── NOW PLAYING ───────────────────────────────────────────────── */
const nowView = {
render() {
const s = state;
const song = s.song;
text($('.as-title'), song ? song.name : s.setlist.name || 'No song');
shell.classList.toggle('is-playing', moving());
songTint(shell, on('songColors') && song ? colorFor(song.color) : null);
text($('.b-tempo'), s.tempo != null ? `${fmtBpm(s.tempo)} BPM` : '');
text($('.b-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
text($('.b-dur'), song && song.duration != null ? fmt(song.duration) : '');
const sect = s.section;
text($('.b-section'), sect ? sect.name : '');
const sc = on('songColors') && sect ? colorFor(sect.color) : null;
$('.b-section').style.color = sc || '';
show($('.b-tempo'), on('showTempo') && s.tempo != null);
show($('.b-meter'), on('showMeter') && Boolean(s.timeSig));
show($('.b-dur'), on('showDuration') && song?.duration != null);
show($('.b-clock'), on('showClock') && pos.remainingSong != null);
show($('.b-section'), on('showSection') && Boolean(sect?.name));
show($('.as-bar'), on('showProgress') && Boolean(song));
const next = s.queued?.type === 'song' ? { name: s.queued.song } : s.nextSong;
text($('.as-next-label'), s.queued?.type === 'song' ? 'Queued:' : 'Next:');
$('.as-next').classList.toggle('is-queued', s.queued?.type === 'song');
text($('.as-next-title'), next ? next.name : 'End of set');
show($('.as-next-label'), on('showNext'));
show($('.as-next-box'), on('showNext'));
show($('.as-left'), on('showLeft') && songsLeft() != null);
this.frame();
},
frame() {
const left = songLeft();
const clockMode = prefs().clock || 'remaining';
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const clock = clockMode === 'elapsed' ? fmt(elapsed)
: clockMode === 'both' ? `${fmt(elapsed)} / -${fmt(left)}` : `-${fmt(left)}`;
const c = $('.b-clock');
text(c, clock);
c.dataset.tone = tone(left);
paintBar($('.as-bar'));
const n = songsLeft();
if (n != null) text($('.as-left'), `${n} song${n === 1 ? '' : 's'} (${fmt(setLeft())}) left`);
},
};
/* ── SONG SECTIONS: a strip, or a lottery wheel ────────────────── */
const WHEEL_STEP = 30; // degrees between wheel faces
let sectionKey = '';
let wheelButton = null;
const sectionsView = {
style() { return prefs().style === 'wheel' ? 'wheel' : 'strip'; },
build() {
const track = $('.as-track');
const list = state.sections || [];
const key = `${state.song?.name}|${list.map((x) => x.name).join('|')}`;
if (key === sectionKey && track.children.length === list.length) return;
sectionKey = key;
track.innerHTML = list.map((x, i) => `<div class="as-pill" data-i="${i}"><span class="as-pill-fill"></span><span class="as-pill-text">${esc(x.name)}</span></div>`).join('')
|| '<div class="as-pill is-empty"><span class="as-pill-text">No sections</span></div>';
this.jump();
},
/** Place without gliding — a new song, or strip ↔ wheel, lands in one step. */
jump() {
const track = $('.as-track');
track.classList.add('no-anim');
requestAnimationFrame(() => requestAnimationFrame(() => track.classList.remove('no-anim')));
},
render() {
const wheel = this.style() === 'wheel';
shell.classList.toggle('is-wheel', wheel);
shell.classList.toggle('is-playing', moving());
text($('.as-song-name'), state.song ? state.song.name : '');
show($('.as-song-name'), on('showSong') && Boolean(state.song));
this.build();
const cur = state.section?.index ?? -1;
const nxt = state.nextSection?.index ?? -1;
const pills = $('.as-track').children;
for (const el of pills) {
const i = Number(el.dataset.i);
if (Number.isNaN(i)) continue;
el.classList.toggle('is-past', i < cur);
el.classList.toggle('is-current', i === cur);
el.classList.toggle('is-next', i === nxt);
const c = on('sectionColors') ? colorFor(state.sections[i]?.color) || (i === cur ? colorFor(state.section?.color) : null) : null;
if (c) el.style.setProperty('--sect', c);
else el.style.removeProperty('--sect');
el.classList.toggle('has-color', Boolean(c));
}
this.place();
this.frame();
},
place() {
const track = $('.as-track');
const vp = $('.as-viewport');
const cur = Math.max(0, state.section?.index ?? 0);
const pills = [...track.children];
if (this.style() === 'wheel') {
const h = vp.clientHeight / 3.4;
const r = (h / 2) / Math.tan((WHEEL_STEP / 2) * (Math.PI / 180));
track.style.setProperty('--face-h', `${h}px`);
pills.forEach((el, i) => {
const d = i - cur;
el.style.transform = `translateY(-50%) rotateX(${-d * WHEEL_STEP}deg) translateZ(${r}px)`;
el.style.opacity = Math.abs(d) * WHEEL_STEP > 75 ? "0" : String(1 - Math.abs(d) * 0.3);
});
track.style.transform = `translateZ(${-r}px)`;
} else {
pills.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; });
const el = pills[cur] || pills[0];
if (!el) return;
const center = el.offsetLeft + el.offsetWidth / 2;
track.style.transform = `translateX(${vp.clientWidth / 2 - center}px)`;
}
},
frame() {
const cur = state.section?.index ?? -1;
const el = $('.as-track').children[cur];
if (!el) return;
const f = fraction(state.section.start, state.section.end, beatsNow());
el.style.setProperty('--p', on('showProgress') && f != null ? `${f * 100}%` : '0%');
},
toggleStyle() {
const next = this.style() === 'wheel' ? 'strip' : 'wheel';
moduleApi.saveInstanceSettings({ style: next });
this.syncButton();
this.jump();
if (state) this.render();
},
syncButton() {
if (!wheelButton) return;
const wheel = this.style() === 'wheel';
wheelButton.innerHTML = wheel ? ICON_STRIP : ICON_WHEEL;
wheelButton.title = wheel ? 'Strip view' : 'Wheel view';
},
};
/* ── SETLIST ───────────────────────────────────────────────────── */
let listKey = '';
let lastScrolled = -2;
const setlistView = {
render() {
const s = state;
text($('.as-set-name'), s.setlist.name || 'Setlist');
const list = $('.as-list');
const songs = s.setlist.songs;
const key = JSON.stringify([songs, on('showDurations'), on('songColors')]);
if (key !== listKey) {
listKey = key;
lastScrolled = -2;
list.innerHTML = songs.map((x, i) => {
const c = on('songColors') ? colorFor(x.color) : null;
return `<div class="as-row" data-i="${i}"${c ? ` style="--song:${c}"` : ''}>
<span class="as-row-fill"></span>
${c ? '<span class="as-swatch"></span>' : ''}
<span class="as-row-name">${esc(x.name)}</span>
${on('showDurations') ? `<span class="as-row-dur">${x.duration != null ? fmt(x.duration) : ''}</span>` : ''}
</div>`;
}).join('') || '<div class="as-empty">No setlist</div>';
}
const cur = s.song?.index ?? -1;
const nxt = s.nextSong?.index ?? -1;
const q = s.queued?.type === 'song' ? s.queued.songIndex : -1;
for (const row of list.children) {
const i = Number(row.dataset.i);
if (Number.isNaN(i)) continue;
row.classList.toggle('is-past', i < cur);
row.classList.toggle('is-current', i === cur);
row.classList.toggle('is-next', i === nxt && q < 0);
row.classList.toggle('is-queued', i === q);
row.hidden = !on('showPlayed') && i < cur;
}
shell.classList.toggle('is-playing', moving());
if (cur !== lastScrolled && list.children[cur]) {
lastScrolled = cur;
const row = list.children[cur];
list.scrollTo({ top: Math.max(0, row.offsetTop - list.clientHeight * 0.3), behavior: 'smooth' });
}
show($('.as-set-foot'), on('showTotal'));
this.frame();
},
frame() {
const row = $('.as-list').children[state.song?.index ?? -1];
if (row && state.song) {
const f = fraction(state.song.start, state.song.end, beatsNow());
row.style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
const n = songsLeft();
text($('.as-set-foot'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left · ${fmt(setLeft())}`);
},
};
/* ── TRANSPORT ─────────────────────────────────────────────────── */
const transportView = {
render() {
const s = state;
let word = 'Stopped';
let t = 'muted';
if (s.loading) { word = 'Loading'; t = 'warn'; }
else if (!s.live) { word = 'No Live'; t = 'alert'; }
else if (s.recording && s.playing) { word = 'Recording'; t = 'alert'; }
else if (s.countingIn) { word = 'Count-in'; t = 'warn'; }
else if (s.playing && (s.loop.inSection || s.sectionLoop?.mode)) { word = 'Looping'; t = 'warn'; }
else if (s.playing) { word = 'Playing'; t = 'ok'; }
else if (s.paused) { word = 'Paused'; t = 'warn'; }
text($('.as-big'), word);
shell.dataset.tone = t;
const loop = s.sectionLoop?.mode
? `Section loop${s.sectionLoop.count ? ` ×${s.sectionLoop.count}` : ''}`
: s.loop.inSection ? 'Section loop' : '';
text($('.as-loop'), loop);
show($('.as-loop'), on('showLoop') && Boolean(loop));
show($('.as-pos'), on('showPosition'));
this.frame();
},
frame() {
const bb = barBeat();
text($('.as-pos'), bb ? `Bar ${bb.bar} · ${bb.beat}` : '');
},
};
/* ── TEMPO ─────────────────────────────────────────────────────── */
let beatCount = 0;
const tempoView = {
render() {
const s = state;
text($('.as-bpm'), fmtBpm(s.tempo));
fit($('.as-big'));
text($('.as-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
show($('.as-meter'), on('showMeter'));
const n = Math.min(16, Math.max(1, s.timeSig ? s.timeSig[0] : 4));
const beats = $('.as-beats');
if (beatCount !== n) {
beatCount = n;
beats.innerHTML = Array.from({ length: n }, (_, i) => `<i class="${i === 0 ? 'is-down' : ''}"></i>`).join('');
}
show(beats, on('showBeats'));
shell.classList.toggle('is-playing', moving());
this.frame();
},
frame() {
const bb = moving() ? barBeat() : null;
const lights = $('.as-beats').children;
for (let i = 0; i < lights.length; i += 1) lights[i].classList.toggle('is-lit', Boolean(bb) && bb.beat === i + 1);
},
};
/* ── SONG TIME ─────────────────────────────────────────────────── */
const songTimeView = {
render() {
const song = state.song;
text($('.as-caption'), song ? song.name : 'Song');
show($('.as-caption'), on('showSong'));
show($('.as-bar'), on('showProgress') && Boolean(song));
this.frame();
},
frame() {
const left = songLeft();
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const mode = prefs().clock || 'remaining';
text($('.as-big'), mode === 'elapsed' ? fmt(elapsed) : `-${fmt(left)}`);
text($('.as-sub-time'), mode === 'both' ? `${fmt(elapsed)} of ${fmt(dur)}` : dur != null ? `of ${fmt(dur)}` : '');
shell.dataset.tone = tone(left) || (moving() ? 'ok' : 'muted');
paintBar($('.as-bar'));
},
};
/* ── SET TIME ──────────────────────────────────────────────────── */
const setTimeView = {
render() {
text($('.as-caption'), state.setlist.name || 'Set');
show($('.as-left-count'), on('showLeft'));
show($('.as-end'), on('showEnd'));
shell.dataset.tone = moving() ? 'ok' : 'muted';
this.frame();
},
frame() {
const left = setLeft();
text($('.as-big'), fmt(left));
const n = songsLeft();
text($('.as-left-count'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left`);
const end = left != null ? new Date(Date.now() + left * 1000) : null;
text($('.as-end'), end ? `Ends ${end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : '');
},
};
/* ── SECTION ───────────────────────────────────────────────────── */
const sectionView = {
render() {
const sect = state.section;
text($('.as-big'), sect ? sect.name : '—');
const c = on('sectionColors') && sect ? colorFor(sect.color) : null;
songTint(shell, c);
const nx = state.queued?.type === 'section' ? state.queued.section : state.nextSection?.name;
text($('.as-next-sect'), nx ? `→ ${nx}` : '');
show($('.as-next-sect'), on('showNext') && Boolean(nx));
show($('.as-count'), on('showCount'));
show($('.as-sect-bar'), on('showCount'));
this.frame();
},
frame() {
const sect = state.section;
const b = beatsNow();
const looping = Boolean(state.sectionLoop?.mode || state.loop.inSection);
let label = '';
let t = moving() ? 'ok' : 'muted';
if (sect && sect.end != null && b != null) {
const leftBeats = Math.max(0, sect.end - b);
const bpb = beatsPerBar();
const beatLen = 4 / (state.timeSig?.[1] || 4);
if (looping) { label = 'Looping'; t = 'warn'; }
else if (leftBeats <= bpb + 1e-6) {
const n = Math.max(1, Math.ceil(leftBeats / beatLen - 1e-6));
label = `${n} beat${n === 1 ? '' : 's'}`;
if (moving()) t = 'warn';
} else {
const n = Math.ceil(leftBeats / bpb - 1e-6);
label = `${n} bars`;
}
const f = fraction(sect.start, sect.end, b);
$('.as-sect-bar').style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
text($('.as-count'), label);
shell.dataset.tone = t;
},
};
/* ── NEXT SONG ─────────────────────────────────────────────────── */
const nextSongView = {
render() {
const n = state.nextSong;
text($('.as-big'), n ? n.name : 'End of set');
text($('.as-next-dur'), n?.duration != null ? fmt(n.duration) : '');
show($('.as-next-dur'), on('showDuration') && n?.duration != null);
const q = state.queued;
const qText = q ? (q.type === 'section' ? `Queued: ${q.section}${q.song ? ` (${q.song})` : ''}` : `Queued: ${q.song}`) : '';
text($('.as-queued'), qText);
show($('.as-queued'), on('showQueued') && Boolean(q));
shell.dataset.tone = q ? 'warn' : n ? '' : 'muted';
},
frame() {},
};
/* ── STATUS ────────────────────────────────────────────────────── */
const statusView = {
render() {
const s = state;
const rows = [
['AbleSet', s.reachable ? 'Connected' : 'No answer', s.reachable ? 'ok' : 'alert'],
['Ableton Live', !s.reachable ? '—' : s.live ? 'Connected' : 'Not connected', !s.reachable ? 'muted' : s.live ? 'ok' : 'alert'],
];
if (s.loading) rows.push(['Project', 'Loading…', 'warn']);
if (s.recording) rows.push(['Recording', 'On', 'alert']);
if (on('showSetlist')) rows.push(['Setlist', s.setlist.name || '—', s.setlist.name ? '' : 'muted']);
if (on('showAudio')) {
if (!s.audio.length) rows.push(['Audio interface', 'None', 'muted']);
for (const a of s.audio) {
const map = {
armed: ['Armed', 'ok'], sceneA: ['Main (A)', 'ok'], sceneB: ['Backup (B)', 'warn'], alarm: ['Alarm', 'alert'],
};
const [label, t] = map[a.status] || [a.status || 'Connected', ''];
rows.push([a.name, s.reachable ? label : '—', s.reachable ? t : 'muted']);
}
}
$('.as-rows').innerHTML = rows.map(([k, v, t]) => `<div class="as-srow" data-tone="${t}"><i></i><span class="as-sk">${esc(k)}</span><span class="as-sv">${esc(v)}</span></div>`).join('');
const worst = rows.some((r) => r[2] === 'alert') ? 'alert' : rows.some((r) => r[2] === 'warn') ? 'warn' : 'ok';
shell.dataset.tone = worst;
},
frame() {},
};
const VIEW_IMPL = {
now: nowView, sections: sectionsView, setlist: setlistView, transport: transportView, tempo: tempoView,
'song-time': songTimeView, 'set-time': setTimeView, section: sectionView, 'next-song': nextSongView, status: statusView,
};
const impl = VIEW_IMPL[view];
/* ── drawing loop ──────────────────────────────────────────────── */
function render() {
if (!impl || !state) return;
const offline = !state.reachable && view !== 'status';
shell.classList.toggle('is-offline', offline);
show(offlineEl, offline);
if (offline) {
text(offlineEl, !state.configured ? 'Set the AbleSet host in /admin' : 'AbleSet offline');
}
impl.render();
loop();
}
function loop() {
if (raf || !moving()) return;
const step = () => {
raf = 0;
if (!state || !moving()) return;
impl.frame();
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function reportStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No AbleSet host set in /admin');
if (!state.reachable) return moduleApi.setStatus('error', state.lastError ? `AbleSet: ${state.lastError}` : 'Waiting for AbleSet to answer');
if (!state.live) return moduleApi.setStatus('connecting', 'AbleSet is up, but Ableton Live is not connected');
return moduleApi.setStatus('ok', state.setlist.name ? `AbleSet · ${state.setlist.name}` : 'AbleSet');
}
function applyState(next) {
if (!next || typeof next !== 'object') return;
takePos(next.pos);
state = next;
reportStatus();
render();
}
/* The page's one stream is shared, so a tile mounted onto it (a gear
change remounts the tile) joins mid-flow and hears nothing until
AbleSet next changes — which, stopped, may be never. Ask for the state. */
let stopped = false;
async function backfill() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) return;
const body = await res.json();
if (!stopped) applyState(body.state);
} catch { /* the stream's error handler reports the outage */ }
}
function takePos(p) {
if (!p) return;
pos = p;
posAt = performance.now();
}
return {
start() {
if (!impl) return;
moduleApi.setStatus('connecting', 'Connecting…');
if (view === 'sections') {
wheelButton = moduleApi.header.addButton({
icon: ICON_WHEEL,
title: 'Wheel view',
onClick: () => sectionsView.toggleStyle(),
});
sectionsView.syncButton();
}
stream = moduleApi.sse('/stream', {
open() { backfill(); },
error() {
moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
},
events: {
state(e) {
let next;
try { next = JSON.parse(e.data); } catch { return; }
applyState(next);
},
pos(e) {
try { takePos(JSON.parse(e.data)); } catch { return; }
if (state && !moving()) impl.frame();
loop();
},
},
});
backfill();
resizeObserver = new ResizeObserver(() => {
fitAll();
if (view === 'sections' && state) sectionsView.place();
});
resizeObserver.observe(shell);
// Stopped tiles still have a clock to keep honest (Set Time's end time).
tickTimer = setInterval(() => { if (state && !moving()) impl.frame(); }, 1000);
},
stop() {
stopped = true;
if (stream) stream.close();
stream = null;
cancelAnimationFrame(raf);
raf = 0;
clearInterval(tickTimer);
if (resizeObserver) resizeObserver.disconnect();
root.innerHTML = '';
},
};
}
+36
View File
@@ -0,0 +1,36 @@
{
"id": "ableset",
"name": "AbleSet",
"version": "1.0.1",
"proddash": ">=1.5.0",
"description": "The song AbleSet is playing — tempo, time left, sections, setlist and status",
"permissions": [
{
"kind": "local-network",
"reason": "Following AbleSet on this network"
}
],
"provides": ["timers"],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 1,
"h": 1
},
"defaultSize": {
"w": 8,
"h": 4
},
"configSchema": {
"ableset": {
"type": "endpoint",
"label": "AbleSet",
"default": {
"host": "",
"port": 39051
},
"help": "The Mac running AbleSet (e.g. AB-Tracks.local). 39051 is AbleSet's OSC port; it only changes if someone changed it in AbleSet's settings."
}
}
}
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/**
* The slice of OSC 1.0 AbleSet speaks — messages and bundles with int32,
* float32, string, blob, true/false/nil — encoded and decoded with Buffer
* alone, so the module needs nothing outside Node.
*/
function pad4(n) {
return (n + 3) & ~3;
}
function encodeString(s) {
const raw = Buffer.from(String(s), 'utf8');
const out = Buffer.alloc(pad4(raw.length + 1));
raw.copy(out);
return out;
}
/**
* One message. Arguments are typed by their JS value: integers are int32,
* other numbers float32, booleans T/F, null nil, everything else a string.
*/
function encodeMessage(address, args = []) {
let tags = ',';
const parts = [];
for (const a of args) {
if (a === null || a === undefined) {
tags += 'N';
} else if (typeof a === 'boolean') {
tags += a ? 'T' : 'F';
} else if (typeof a === 'number' && Number.isInteger(a)) {
tags += 'i';
const b = Buffer.alloc(4);
b.writeInt32BE(a);
parts.push(b);
} else if (typeof a === 'number') {
tags += 'f';
const b = Buffer.alloc(4);
b.writeFloatBE(a);
parts.push(b);
} else {
tags += 's';
parts.push(encodeString(a));
}
}
return Buffer.concat([encodeString(address), encodeString(tags), ...parts]);
}
function readString(buf, at) {
let end = at;
while (end < buf.length && buf[end] !== 0) end += 1;
if (end >= buf.length) throw new Error('unterminated OSC string');
return { value: buf.toString('utf8', at, end), next: pad4(end + 1) };
}
function decodeMessage(buf, out) {
const addr = readString(buf, 0);
let at = addr.next;
const args = [];
if (at < buf.length && buf[at] === 0x2c) {
const tagStr = readString(buf, at);
at = tagStr.next;
for (const t of tagStr.value.slice(1)) {
switch (t) {
case 'i': args.push(buf.readInt32BE(at)); at += 4; break;
case 'f': args.push(buf.readFloatBE(at)); at += 4; break;
case 'd': args.push(buf.readDoubleBE(at)); at += 8; break;
case 'h': args.push(Number(buf.readBigInt64BE(at))); at += 8; break;
case 's': case 'S': { const s = readString(buf, at); args.push(s.value); at = s.next; break; }
case 'b': { const n = buf.readInt32BE(at); args.push(buf.subarray(at + 4, at + 4 + n)); at = pad4(at + 4 + n); break; }
case 'T': args.push(true); break;
case 'F': args.push(false); break;
case 'N': args.push(null); break;
case 'I': args.push(Infinity); break;
case 't': at += 8; break;
default: throw new Error(`unsupported OSC type tag "${t}"`);
}
}
}
out.push({ address: addr.value, args });
}
/** Every message in a packet, bundles flattened, in order. */
function decodePacket(buf, out = []) {
if (buf.length >= 16 && buf.toString('ascii', 0, 8) === '#bundle\0') {
let at = 16; // "#bundle\0" + 8-byte timetag
while (at + 4 <= buf.length) {
const n = buf.readInt32BE(at);
at += 4;
if (n <= 0 || at + n > buf.length) break;
decodePacket(buf.subarray(at, at + n), out);
at += n;
}
return out;
}
decodeMessage(buf, out);
return out;
}
module.exports = { encodeMessage, decodePacket };
+719
View File
@@ -0,0 +1,719 @@
'use strict';
/**
* AbleSet — server part.
*
* Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet
* listens on 39051). `/subscribe auto <port>` makes AbleSet push every value
* that changes to our socket, `/getValues` fills in everything that is
* already true, and AbleSet's `/heartbeat` every 2 s is how we know it is
* still there — when heartbeats stop (AbleSet quit, the Mac slept, the
* network dropped) we keep asking again every few seconds until it answers,
* because a restarted AbleSet has forgotten its subscribers.
*
* The raw values become one view-model, streamed to every tile:
*
* GET /state → { state } (instant paint)
* GET /stream → SSE: `state` on any change, `pos` while playing
* GET /timers(/stream) → song and set countdowns for the Timers module
*
* `pos` carries only what moves every frame (playhead, time left); the
* tiles extrapolate between packets from the tempo, so 5 packets a second
* draws a smooth playhead.
*/
const dgram = require('dgram');
const dns = require('dns');
const { encodeMessage, decodePacket } = require('./osc');
const HEARTBEAT_MS = 15000; // SSE keepalive comment
const SILENT_MS = 5000; // no heartbeat for this long → AbleSet is gone
const RESUBSCRIBE_MS = 3000; // how often to ask again while it is gone
const POS_MS = 200; // playhead packets to tiles while playing
const COALESCE_MS = 40; // AbleSet sends a section change as a burst of addresses
const FINE_MS = 100; // fine-position throttle we ask AbleSet for
/** Things AbleSet can send that no tile draws — kept off the wire. */
const EXCLUDES = ['!/mixer/**', '!/lyrics/**', '!/cues/**', '!/devices/**', '!/settings/**', '!/midi/**', '!/shared/**'];
let current = null;
function emptyState() {
return {
reachable: false,
configured: false,
lastError: '',
live: false,
loading: false,
playing: false,
paused: false,
recording: false,
countingIn: false,
tempo: null,
timeSig: null,
setlist: { name: '', songs: [] },
song: null,
section: null,
nextSection: null,
sections: [],
nextSong: null,
queued: null,
loop: { enabled: false, start: null, end: null, inSection: false },
audio: [],
version: '',
pos: emptyPos(),
};
}
function emptyPos() {
return { beats: null, remainingSong: null, remainingSet: null };
}
module.exports = {
init({ config, log }) {
const ep = config.ableset && typeof config.ableset === 'object' ? config.ableset : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 39051;
const configured = Boolean(host);
const values = new Map(); // OSC address → args, as AbleSet last said
const streams = new Set(); // tile SSE clients
const timerStreams = new Set(); // Timers-module SSE clients
let lastHeard = 0;
let lastAsked = 0;
let resolved = '';
let lastError = '';
let sock = null;
let stopped = false;
let coalesce = null;
let lastFrame = '';
let lastTimersFrame = '';
// Playhead and countdowns, each as "value at a moment" so they can be
// extrapolated to any other moment. AbleSet's time-left values are whole
// seconds; restamping on every one of them makes a countdown stutter, so
// a model is only moved when AbleSet disagrees with it by over a second.
const beatsModel = { value: null, at: 0 };
const songModel = { value: null, at: 0 };
const setModel = { value: null, at: 0 };
const num = (a, i = 0) => {
const v = values.get(a);
const n = v ? Number(v[i]) : NaN;
return v && v[i] !== null && Number.isFinite(n) ? n : null;
};
const str = (a, i = 0) => {
const v = values.get(a);
return v && v[i] != null ? String(v[i]) : '';
};
const arr = (a) => values.get(a) || [];
const flag = (a) => num(a) === 1 || values.get(a)?.[0] === true;
const isPlaying = () => flag('/global/isPlaying');
const tempo = () => num('/global/tempo');
function extrapolate(model, sign) {
if (model.value == null) return null;
if (!isPlaying()) return model.value;
return model.value + sign * (Date.now() - model.at) / 1000;
}
function beatsNow() {
if (beatsModel.value == null) return null;
const t = tempo();
if (!isPlaying() || !t) return beatsModel.value;
return beatsModel.value + ((Date.now() - beatsModel.at) / 1000) * (t / 60);
}
function nudge(model, reported) {
if (reported == null) { model.value = null; return; }
const predicted = extrapolate(model, -1);
if (predicted == null || Math.abs(predicted - reported) > 1.05 || !isPlaying()) {
model.value = reported;
model.at = Date.now();
} else {
// Keep the running model; re-base it so the stamp stays recent.
model.value = predicted;
model.at = Date.now();
}
}
function colorOf(a) {
const v = values.get(a);
if (!v) return null;
const name = v[0] == null ? '' : String(v[0]);
const hex = v[1] == null ? '' : String(v[1]);
return name && name !== 'default' && /^#[0-9a-f]{6}$/i.test(hex) ? hex : null;
}
function part(prefix) {
const name = str(`${prefix}Name`);
if (!name && num(`${prefix}Index`) == null) return null;
const index = num(`${prefix}Index`);
if (index != null && index < 0) return null;
return {
name,
index,
start: num(`${prefix}Start`),
end: num(`${prefix}End`),
duration: num(`${prefix}Duration`),
color: colorOf(`${prefix}Color`),
};
}
function audioInterfaces() {
const out = [];
const names = arr('/audioInterfaces/names');
const statuses = arr('/audioInterfaces/status');
names.forEach((n, i) => {
out.push({ name: String(n || `Interface ${i + 1}`), status: String(statuses[i] || '') });
});
// AbleSet 2 reports its PlayAUDIO12 on its own addresses.
if (!names.length && flag('/playaudio12/isConnected')) {
const scene = num('/playaudio12/scene');
out.push({ name: 'PlayAUDIO12', status: scene === 2 ? 'sceneB' : scene === 1 ? 'sceneA' : '' });
}
return out;
}
function buildState() {
const songs = arr('/setlist/songs');
const durations = arr('/setlist/songDurations');
const colors = arr('/setlist/songColors');
const sectionNames = arr('/setlist/sections');
const sectionColors = arr('/setlist/sectionColors');
const song = part('/setlist/activeSong');
const qIdx = values.get('/setlist/queuedIndex') || [];
const qName = values.get('/setlist/queuedName') || [];
const queuedType = str('/setlist/queuedType');
const ts = values.get('/global/timeSignature');
return {
reachable: isReachable(),
configured,
lastError,
live: flag('/global/isConnected'),
loading: flag('/global/isLoadingFile'),
playing: isPlaying(),
paused: flag('/global/isPaused'),
recording: flag('/global/isRecording'),
countingIn: flag('/setlist/isCountingIn'),
tempo: tempo(),
timeSig: ts && ts.length >= 2 && ts[0] != null ? [Number(ts[0]), Number(ts[1])] : null,
setlist: {
name: str('/setlist/name'),
songs: songs.map((name, i) => ({
name: String(name ?? ''),
duration: durations[i] == null ? null : Number(durations[i]),
color: typeof colors[i] === 'string' && colors[i] !== 'default' ? colors[i] : null,
})),
},
song,
section: part('/setlist/activeSection'),
nextSection: part('/setlist/nextSection'),
sections: sectionNames.map((name, i) => ({
name: String(name ?? ''),
color: typeof sectionColors[i] === 'string' && sectionColors[i] !== 'default' ? sectionColors[i] : null,
})),
sectionLoop: {
mode: str('/setlist/activeSectionLoopMode'),
count: num('/setlist/activeSectionLoopCount'),
},
nextSong: part('/setlist/nextSong'),
queued: queuedType && qIdx[0] != null && Number(qIdx[0]) >= 0
? { type: queuedType, song: String(qName[0] ?? ''), section: String(qName[1] ?? ''), songIndex: Number(qIdx[0]), sectionIndex: qIdx[1] == null ? -1 : Number(qIdx[1]) }
: null,
loop: {
enabled: flag('/setlist/loopEnabled'),
start: num('/setlist/loopStart'),
end: num('/setlist/loopEnd'),
inSection: flag('/setlist/isInActiveSectionLoop'),
},
audio: audioInterfaces(),
version: str('/version/app'),
pos: buildPos(),
};
}
/**
* Time left, as AbleSet counts it. AbleSet only sends these while Live is
* connected; without them the song's length and the playhead still say
* how far there is to go, and the set is the rest of the setlist on top.
*/
function songLeftNow() {
const reported = extrapolate(songModel, -1);
if (reported != null) return reported;
const dur = num('/setlist/activeSongDuration');
const start = num('/setlist/activeSongStart');
const end = num('/setlist/activeSongEnd');
const b = beatsNow();
if (dur == null || start == null || end == null || end <= start || b == null) return null;
return Math.max(0, dur * (1 - Math.min(1, Math.max(0, (b - start) / (end - start)))));
}
function setLeftNow() {
const reported = extrapolate(setModel, -1);
if (reported != null) return reported;
const song = songLeftNow();
const idx = num('/setlist/activeSongIndex');
if (song == null || idx == null) return null;
return song + arr('/setlist/songDurations').slice(idx + 1).reduce((a, d) => a + (Number(d) || 0), 0);
}
function buildPos() {
const round = (v, d) => (v == null ? null : Math.round(v * d) / d);
return {
beats: round(beatsNow(), 1000),
remainingSong: round(songLeftNow(), 100),
remainingSet: round(setLeftNow(), 100),
};
}
function isReachable() {
return lastHeard > 0 && Date.now() - lastHeard < SILENT_MS;
}
/* ── fan-out ─────────────────────────────────────────────────── */
function send(set, payload) {
for (const res of set) {
try { res.write(payload); } catch { set.delete(res); }
}
}
function broadcastState() {
coalesce = null;
const state = buildState();
// Change detection ignores the moving parts, which `pos` carries.
const { pos, ...still } = state;
const frame = JSON.stringify(still);
if (frame !== lastFrame) {
lastFrame = frame;
if (streams.size) send(streams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
broadcastTimers();
}
function scheduleState() {
if (!coalesce) coalesce = setTimeout(broadcastState, COALESCE_MS);
}
function broadcastTimers() {
if (!timerStreams.size) return;
const snap = timersSnapshot();
const { updatedAt, ...rest } = snap;
const key = JSON.stringify(rest.timers.map((t) => [t.state, t.label, t.status, t.targetMs]));
if (key === lastTimersFrame) return;
lastTimersFrame = key;
send(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot() {
const now = Date.now();
const playing = isPlaying();
const reachable = isReachable();
const song = part('/setlist/activeSong');
const remSong = songLeftNow();
const remSet = setLeftNow();
const state = !reachable ? 'idle' : playing ? 'running' : remSong != null ? 'paused' : 'idle';
const ms = (s) => (s == null ? null : Math.max(0, Math.round(s * 1000)));
const songElapsed = song?.duration != null && remSong != null ? ms(song.duration - remSong) : null;
const songs = arr('/setlist/songs');
const idx = num('/setlist/activeSongIndex');
const left = idx == null ? null : Math.max(0, songs.length - idx - 1);
return {
source: 'ableset',
label: 'AbleSet',
updatedAt: now,
timers: [
{
id: 'song',
label: 'Song',
kind: 'countdown',
state,
startedAt: playing && songElapsed != null ? now - songElapsed : null,
elapsedMs: songElapsed,
remainingMs: ms(remSong),
targetMs: ms(song?.duration),
status: song ? { text: song.name, tone: reachable ? 'ok' : 'muted' } : null,
detail: str('/setlist/activeSectionName') || null,
},
{
id: 'set',
label: 'Set',
kind: 'countdown',
state,
startedAt: null,
elapsedMs: null,
remainingMs: ms(remSet),
targetMs: null,
status: str('/setlist/name') ? { text: str('/setlist/name'), tone: 'muted' } : null,
detail: left == null ? null : `${left} song${left === 1 ? '' : 's'} left`,
},
],
};
}
/* ── OSC in ──────────────────────────────────────────────────── */
const FAST = new Set([
'/global/beatsPosition', '/global/finePosition', '/setlist/activeSongProgress',
'/setlist/activeSectionProgress', '/setlist/remainingTimeInSong', '/setlist/remainingTimeInSet',
'/global/humanPosition', '/global/currentMeasure', '/global/measureOrPosition',
'/global/cpuAverage', '/global/cpuPeak', '/heartbeat', '/subscribed',
]);
function onMessage(m) {
const { address, args } = m;
if (address === '/global/finePosition' || address === '/global/beatsPosition') {
const v = Number(args[0]);
if (Number.isFinite(v)) {
// A whole-beat report while fine ones flow would drag the playhead back.
if (address === '/global/beatsPosition' && values.has('/global/finePosition')
&& Math.abs((beatsNow() ?? v) - v) < 1) return void values.set(address, args);
beatsModel.value = v;
beatsModel.at = Date.now();
}
} else if (address === '/setlist/remainingTimeInSong') {
nudge(songModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/setlist/remainingTimeInSet') {
nudge(setModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/global/isPlaying') {
// Freeze the models at the moment the transport changed.
const b = beatsNow();
if (b != null) { beatsModel.value = b; beatsModel.at = Date.now(); }
for (const mdl of [songModel, setModel]) {
const v = extrapolate(mdl, -1);
if (v != null) { mdl.value = v; mdl.at = Date.now(); }
}
}
values.set(address, args);
if (!FAST.has(address)) scheduleState();
}
/* ── connection ──────────────────────────────────────────────── */
function ask() {
if (!sock || stopped) return;
lastAsked = Date.now();
dns.lookup(host, { family: 4 }, (err, addr) => {
if (stopped || !sock) return;
if (err) {
lastError = `can't find ${host} (${err.code || err.message})`;
scheduleState();
return;
}
resolved = addr;
const myPort = sock.address().port;
const out = (address, args) => {
try {
sock.send(encodeMessage(address, args), port, addr, (e) => {
if (e) lastError = e.message;
});
} catch (e) {
lastError = e.message;
}
};
out('/subscribe', ['auto', myPort, 'ProdDash', FINE_MS, ...EXCLUDES]);
out('/getValues', ['auto', myPort]);
});
}
const watchdog = setInterval(() => {
if (!configured || stopped) return;
const wasReachable = lastHeard > 0 && Date.now() - lastHeard < SILENT_MS + 1000;
if (!isReachable()) {
if (wasReachable) {
log(`no heartbeat from AbleSet at ${host} — asking again`);
lastError = 'AbleSet stopped answering';
scheduleState();
}
if (Date.now() - lastAsked >= RESUBSCRIBE_MS) ask();
}
}, 1000);
watchdog.unref?.();
// While playing, the playhead goes out five times a second. While
// stopped it only goes out when it moved — a jump, a scrub.
let lastPos = '';
const posTimer = setInterval(() => {
if (!streams.size || !isReachable()) return;
const frame = JSON.stringify(buildPos());
if (!isPlaying() && frame === lastPos) return;
lastPos = frame;
send(streams, `event: pos\ndata: ${frame}\n\n`);
}, POS_MS);
posTimer.unref?.();
const keepalive = setInterval(() => {
send(streams, ': ping\n\n');
send(timerStreams, ': ping\n\n');
// The Timers module derives running values itself; a fresh anchor now
// and then keeps a long song honest.
if (timerStreams.size) {
lastTimersFrame = '';
broadcastTimers();
}
}, HEARTBEAT_MS);
keepalive.unref?.();
if (configured) {
sock = dgram.createSocket('udp4');
sock.on('message', (buf, rinfo) => {
if (resolved && rinfo.address !== resolved) return; // only AbleSet talks to us
let msgs;
try {
msgs = decodePacket(buf);
} catch (e) {
log(`unreadable OSC packet from ${rinfo.address}: ${e.message}`);
return;
}
const wasReachable = isReachable();
lastHeard = Date.now();
if (!wasReachable) {
lastError = '';
log(`AbleSet at ${host} (${rinfo.address}) is answering`);
scheduleState();
}
for (const m of msgs) onMessage(m);
});
sock.on('error', (e) => {
lastError = e.message;
log(`OSC socket error: ${e.message}`);
});
sock.bind(0, () => {
log(`following AbleSet at ${host}:${port}`);
ask();
});
} else {
log('no AbleSet host configured — set one in /admin');
}
current = {
streams,
timerStreams,
getState: buildState,
getTimers: timersSnapshot,
};
return {
stop() {
stopped = true;
clearInterval(watchdog);
clearInterval(posTimer);
clearInterval(keepalive);
clearTimeout(coalesce);
if (sock) {
// Be polite: tell AbleSet to stop sending to a port nobody reads.
const s = sock;
sock = null;
try {
if (resolved) s.send(encodeMessage('/unsubscribe', ['auto', s.address().port]), port, resolved, () => { try { s.close(); } catch { /* closed */ } });
else s.close();
} catch {
try { s.close(); } catch { /* closed */ }
}
}
for (const set of [streams, timerStreams]) {
for (const res of set) { try { res.end(); } catch { /* gone */ } }
set.clear();
}
if (current && current.streams === streams) current = null;
},
health() {
if (!configured) return { status: 'error', message: 'No AbleSet host configured' };
if (isReachable()) {
const live = flag('/global/isConnected') ? '' : ' — Ableton Live is not connected';
const set = str('/setlist/name');
return { status: live ? 'connecting' : 'ok', message: `Following ${host}${set ? ` · ${set}` : ''}${live}` };
}
if (lastError) return { status: 'error', message: `AbleSet at ${host}: ${lastError}` };
return { status: 'connecting', message: `Waiting for AbleSet at ${host}:${port} to answer…` };
},
};
},
/**
* One picker entry per tile kind. Each carries only the gear settings its
* tile can act on (guide rules 5–8): four or fewer switches sit loose, the
* Now Playing card's longer list folds into a Show group.
*/
tiles() {
const sw = (label, extra = {}) => ({ type: 'switch', label, default: true, ...extra });
const clock = {
type: 'select',
label: 'Song time',
default: 'remaining',
options: [
{ value: 'remaining', label: 'Time left' },
{ value: 'elapsed', label: 'Elapsed' },
{ value: 'both', label: 'Both' },
],
};
const warnAt = { type: 'number', label: 'Warn at (s)', default: 30, group: 'Colours' };
const alertAt = { type: 'number', label: 'Alert at (s)', default: 10, group: 'Colours' };
return [
{
id: 'now',
name: 'Now Playing',
description: 'The song, its tempo, time left, section, progress and what comes next',
defaultSize: { w: 8, h: 4 },
minSize: { w: 3, h: 2 },
instanceSchema: {
clock,
songColors: sw('Song colours', { default: false }),
showTempo: sw('Tempo', { group: 'Show' }),
showMeter: sw('Time signature', { group: 'Show' }),
showDuration: sw('Duration', { group: 'Show' }),
showClock: sw('Song time', { group: 'Show' }),
showSection: sw('Section', { group: 'Show' }),
showProgress: sw('Progress bar', { group: 'Show' }),
showNext: sw('Next song', { group: 'Show' }),
showLeft: sw('Songs left', { group: 'Show' }),
warnAt,
alertAt,
},
instanceGroups: { Show: { collapsed: true, columns: 2 }, Colours: { collapsed: true, columns: 2 } },
},
{
id: 'sections',
name: 'Song Sections',
description: 'Where the song is — its sections sliding past, as a strip or a wheel',
defaultSize: { w: 8, h: 2 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showProgress: sw('Section progress'),
showSong: sw('Song name', { default: false }),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'setlist',
name: 'Setlist',
description: 'Every song in the set, played, playing and to come',
defaultSize: { w: 3, h: 6 },
minSize: { w: 2, h: 2 },
instanceSchema: {
showDurations: sw('Durations'),
showPlayed: sw('Played songs'),
showTotal: sw('Time left'),
songColors: sw('Song colours', { default: false }),
},
},
{
id: 'transport',
name: 'Transport',
description: 'Playing, stopped, counting in or looping, in colour',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showPosition: sw('Bar and beat'),
showLoop: sw('Loop'),
},
},
{
id: 'tempo',
name: 'Tempo',
description: 'BPM and time signature, with a light on every beat',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showMeter: sw('Time signature'),
showBeats: sw('Beat lights'),
},
},
{
id: 'song-time',
name: 'Song Time',
description: 'Time left in the song, turning amber then red near the end',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
clock,
showSong: sw('Song name'),
showProgress: sw('Progress bar'),
warnAt,
alertAt,
},
instanceGroups: { Colours: { collapsed: true, columns: 2 } },
},
{
id: 'set-time',
name: 'Set Time',
description: 'Time left in the whole set and how many songs remain',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showLeft: sw('Songs left'),
showEnd: sw('End time'),
},
},
{
id: 'section',
name: 'Section',
description: 'The section playing now, what is next, and the count to the change',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showNext: sw('Next section'),
showCount: sw('Countdown'),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'next-song',
name: 'Next Song',
description: 'The song after this one, and anything queued',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showDuration: sw('Duration'),
showQueued: sw('Queued'),
},
},
{
id: 'status',
name: 'AbleSet Status',
description: 'AbleSet, Ableton Live and audio interface health at a glance',
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showAudio: sw('Audio interfaces'),
showSetlist: sw('Setlist name'),
},
},
];
},
routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
const openSse = (req, res, set, first) => {
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(first);
if (!set) return void res.end();
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
set.add(res);
req.on('close', () => set.delete(res));
};
return {
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
'GET /stream': (req, res) => {
const state = current ? current.getState() : emptyState();
openSse(req, res, current ? current.streams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
},
'GET /timers': (req, res) => sendJson(res, 200, current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] }),
'GET /timers/stream': (req, res) => {
const snap = current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] };
openSse(req, res, current ? current.timerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
},
};
},
};
+416
View File
@@ -0,0 +1,416 @@
/* AbleSet tiles. Every size hangs off the tile itself (container units), so
a tile reads the same at 2×1 on a laptop as at 12×6 on a wall. Colours are
the shell's variables; a tone (ok / warn / alert / muted) picks among them,
and AbleSet's own song or section colour arrives as --song when a tile is
set to use it. */
[data-module="ableset"] .as-root {
--tone: var(--text);
--live: var(--accent);
position: absolute;
inset: 0;
container-type: size;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
color: var(--text);
font-variant-numeric: tabular-nums;
}
[data-module="ableset"] .as-root.has-song-color { --live: var(--song); }
[data-module="ableset"] .as-root[data-tone="ok"] { --tone: var(--accent); }
[data-module="ableset"] .as-root[data-tone="warn"] { --tone: var(--warn); }
[data-module="ableset"] .as-root[data-tone="alert"] { --tone: var(--danger); }
[data-module="ableset"] .as-root[data-tone="muted"] { --tone: var(--muted); }
[data-module="ableset"] .as-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: clamp(4px, 4cqmin, 28px);
gap: clamp(2px, 2.5cqh, 18px);
min-width: 0;
}
[data-module="ableset"] [hidden] { display: none !important; }
[data-module="ableset"] .as-offline {
position: absolute;
top: clamp(3px, 3cqmin, 14px);
right: clamp(4px, 4cqmin, 18px);
z-index: 2;
font-size: clamp(9px, 6cqmin, 15px);
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-module="ableset"] .as-root.is-offline .as-body { opacity: 0.35; filter: grayscale(0.7); }
[data-module="ableset"] [data-fit] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── the song bar: played sections · this section · the playhead ── */
[data-module="ableset"] .as-bar {
--done: 0%;
--sect-l: 0%;
--sect-w: 0%;
--played: 0%;
position: relative;
flex-shrink: 0;
height: clamp(4px, 5.5cqh, 26px);
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--live) 14%, var(--panel));
}
[data-module="ableset"] .as-bar-done {
position: absolute;
inset: 0 auto 0 0;
width: var(--done);
background: color-mix(in srgb, var(--live) 32%, var(--panel));
}
[data-module="ableset"] .as-bar-sect {
position: absolute;
top: 0;
bottom: 0;
left: var(--sect-l);
width: var(--sect-w);
background: color-mix(in srgb, var(--live) 55%, var(--panel));
overflow: hidden;
}
[data-module="ableset"] .as-bar-played {
position: absolute;
inset: 0 auto 0 0;
width: var(--played);
background: linear-gradient(90deg,
color-mix(in srgb, var(--live) 70%, var(--panel)) 0%,
color-mix(in srgb, var(--live) 70%, var(--panel)) calc(100% - 3.5cqw),
color-mix(in srgb, var(--live) 70%, var(--text)) 100%);
}
/* ── NOW PLAYING ─────────────────────────────────────────────────── */
[data-module="ableset"] .as-now .as-body {
align-items: center;
padding-inline: clamp(6px, 7cqw, 90px);
gap: clamp(3px, 4cqh, 30px);
}
[data-module="ableset"] .as-now .as-title-box,
[data-module="ableset"] .as-now .as-next-box { width: 100%; display: flex; justify-content: center; min-width: 0; }
[data-module="ableset"] .as-now .as-title {
font-size: clamp(14px, 20cqh, 170px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.01em;
color: var(--text);
transition: color 0.3s;
}
[data-module="ableset"] .as-now.is-playing .as-title { color: var(--live); }
[data-module="ableset"] .as-now .as-bar { width: 100%; }
[data-module="ableset"] .as-badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(3px, 1.8cqh, 16px);
max-width: 100%;
}
[data-module="ableset"] .as-badge {
font-size: clamp(9px, 7cqh, 44px);
font-weight: 700;
line-height: 1;
padding: 0.28em 0.5em;
border-radius: 0.22em;
background: color-mix(in srgb, var(--text) 12%, var(--panel));
color: color-mix(in srgb, var(--text) 80%, var(--accent));
white-space: nowrap;
}
[data-module="ableset"] .as-badge.b-section {
color: var(--text);
text-transform: uppercase;
background: color-mix(in srgb, var(--text) 7%, var(--panel));
}
[data-module="ableset"] .as-badge.b-clock[data-tone="warn"] { color: var(--accent-text); background: var(--warn); }
[data-module="ableset"] .as-badge.b-clock[data-tone="alert"] { color: var(--accent-text); background: var(--danger); }
[data-module="ableset"] .as-next {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
gap: clamp(1px, 0.6cqh, 6px);
}
[data-module="ableset"] .as-next-label {
font-size: clamp(8px, 5cqh, 30px);
font-weight: 700;
color: color-mix(in srgb, var(--live) 55%, var(--muted));
}
[data-module="ableset"] .as-next.is-queued .as-next-label { color: var(--warn); }
[data-module="ableset"] .as-next-title {
font-size: clamp(11px, 12cqh, 90px);
font-weight: 500;
line-height: 1.1;
color: color-mix(in srgb, var(--live) 45%, var(--muted));
}
[data-module="ableset"] .as-left {
font-size: clamp(8px, 6cqh, 36px);
color: color-mix(in srgb, var(--live) 40%, var(--muted));
}
/* ── SONG SECTIONS ──────────────────────────────────────────────── */
[data-module="ableset"] .as-sections .as-body { padding: clamp(3px, 3cqmin, 16px) 0; gap: clamp(2px, 2cqh, 10px); }
[data-module="ableset"] .as-sections .as-song-name {
flex-shrink: 0;
text-align: center;
padding: 0 clamp(4px, 3cqw, 20px);
font-size: clamp(9px, 13cqh, 28px);
font-weight: 700;
color: var(--muted);
}
[data-module="ableset"] .as-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
[data-module="ableset"] .as-track {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
gap: clamp(4px, 2cqw, 18px);
transition: transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
will-change: transform;
}
[data-module="ableset"] .as-track.no-anim,
[data-module="ableset"] .as-track.no-anim .as-pill { transition: none !important; }
[data-module="ableset"] .as-pill {
--p: 0%;
--sect: var(--accent);
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
height: min(78%, 22cqw);
padding: 0 0.8em;
border-radius: 0.35em;
font-size: clamp(9px, 26cqh, 64px);
font-weight: 700;
line-height: 1;
white-space: nowrap;
overflow: hidden;
color: var(--muted);
background: color-mix(in srgb, var(--text) 8%, var(--panel));
border: 2px solid transparent;
transition: background 0.4s, color 0.4s, border-color 0.4s, opacity 0.5s, transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
}
[data-module="ableset"] .as-pill.has-color { background: color-mix(in srgb, var(--sect) 22%, var(--panel)); }
[data-module="ableset"] .as-pill.is-past { opacity: 0.45; }
[data-module="ableset"] .as-pill.is-next { border-color: color-mix(in srgb, var(--sect) 60%, transparent); color: var(--text); }
[data-module="ableset"] .as-pill.is-current {
color: var(--accent-text);
background: color-mix(in srgb, var(--sect) 55%, var(--panel));
}
[data-module="ableset"] .as-pill-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: var(--sect);
}
[data-module="ableset"] .as-pill.is-current .as-pill-fill { width: var(--p); }
[data-module="ableset"] .as-pill-text { position: relative; }
[data-module="ableset"] .as-pill.is-empty { color: var(--muted); }
/* the lottery wheel: faces on a drum turning towards the viewer */
[data-module="ableset"] .is-wheel .as-viewport {
perspective: 900px;
-webkit-mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
}
[data-module="ableset"] .is-wheel .as-track {
--face-h: 30px;
inset: 50% 0 auto 0;
display: block;
height: 0;
transform-style: preserve-3d;
transition: none;
}
[data-module="ableset"] .is-wheel .as-pill {
position: absolute;
left: 50%;
top: 0;
translate: -50% 0;
height: var(--face-h);
justify-content: center;
min-width: 55%;
max-width: 94%;
font-size: clamp(9px, calc(var(--face-h) * 0.55), 72px);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* ── SETLIST ─────────────────────────────────────────────────────── */
[data-module="ableset"] .as-setlist .as-body { justify-content: flex-start; gap: 6px; padding: 8px; }
[data-module="ableset"] .as-set-head {
flex-shrink: 0;
font-size: 0.8rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="ableset"] .as-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; scrollbar-width: thin; }
[data-module="ableset"] .as-row {
--p: 0%;
--song: var(--accent);
position: relative;
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.35em 0.55em;
border-radius: 6px;
font-size: clamp(11px, 5.5cqw, 19px);
background: color-mix(in srgb, var(--text) 5%, transparent);
overflow: hidden;
flex-shrink: 0;
}
[data-module="ableset"] .as-row-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: color-mix(in srgb, var(--accent) 22%, transparent); }
[data-module="ableset"] .as-row.is-current .as-row-fill { width: var(--p); }
[data-module="ableset"] .as-row-name { position: relative; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
[data-module="ableset"] .as-row-dur { position: relative; color: var(--muted); font-size: 0.85em; }
[data-module="ableset"] .as-swatch { position: relative; width: 0.55em; height: 0.55em; border-radius: 50%; background: var(--song); flex-shrink: 0; }
[data-module="ableset"] .as-row.is-past { opacity: 0.4; }
[data-module="ableset"] .as-row.is-current { font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: inset 3px 0 0 var(--accent); }
[data-module="ableset"] .as-row.is-next { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--text) 40%, transparent); }
[data-module="ableset"] .as-row.is-queued { box-shadow: inset 3px 0 0 var(--warn); color: var(--warn); }
[data-module="ableset"] .as-set-foot { flex-shrink: 0; font-size: 0.85rem; color: var(--muted); text-align: right; }
[data-module="ableset"] .as-empty { color: var(--muted); padding: 1em; text-align: center; }
/* ── single readouts: transport, tempo, times, section, next ────── */
[data-module="ableset"] .as-big-box { display: flex; justify-content: center; min-width: 0; width: 100%; }
[data-module="ableset"] .as-big {
font-size: clamp(14px, 38cqh, 220px);
font-weight: 800;
line-height: 1;
color: var(--tone);
text-align: center;
transition: color 0.3s;
}
[data-module="ableset"] .as-big small { font-size: 0.35em; font-weight: 700; color: var(--muted); }
[data-module="ableset"] .as-caption {
text-align: center;
font-size: clamp(8px, 12cqh, 30px);
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
[data-module="ableset"] .as-sub {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.25em 1em;
font-size: clamp(8px, 12cqh, 34px);
color: var(--muted);
text-align: center;
white-space: nowrap;
}
[data-module="ableset"] .as-sub:empty { display: none; }
/* A tone washes the tile's background, so a wall of them reads at a glance. */
[data-module="ableset"] .as-transport,
[data-module="ableset"] .as-song-time,
[data-module="ableset"] .as-section,
[data-module="ableset"] .as-status,
[data-module="ableset"] .as-next-song {
background: color-mix(in srgb, var(--tone) 9%, transparent);
transition: background 0.3s;
}
[data-module="ableset"] .as-root[data-tone="muted"],
[data-module="ableset"] .as-root[data-tone=""] { background: none; }
[data-module="ableset"] .as-transport .as-big { text-transform: uppercase; font-size: clamp(12px, 34cqh, 180px); }
[data-module="ableset"] .as-transport .as-loop { color: var(--warn); font-weight: 700; }
[data-module="ableset"] .as-tempo .as-big { color: var(--text); }
[data-module="ableset"] .as-beats { display: flex; justify-content: center; gap: clamp(3px, 3cqw, 14px); flex-shrink: 0; }
[data-module="ableset"] .as-beats i {
width: clamp(6px, 11cqh, 30px);
aspect-ratio: 1;
border-radius: 50%;
background: color-mix(in srgb, var(--text) 14%, var(--panel));
transition: background 0.08s;
}
[data-module="ableset"] .as-beats i.is-lit { background: var(--text); }
[data-module="ableset"] .as-beats i.is-down.is-lit { background: var(--accent); }
[data-module="ableset"] .as-song-time .as-bar { height: clamp(3px, 5cqh, 14px); }
[data-module="ableset"] .as-section .as-big { text-transform: uppercase; }
[data-module="ableset"] .as-section.has-song-color .as-big { color: var(--song); }
[data-module="ableset"] .as-section .as-count { color: var(--tone); font-weight: 700; }
[data-module="ableset"] .as-sect-bar {
--p: 0%;
height: clamp(3px, 4cqh, 12px);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 12%, var(--panel));
overflow: hidden;
flex-shrink: 0;
}
[data-module="ableset"] .as-sect-bar > div { height: 100%; width: var(--p); background: var(--tone); }
[data-module="ableset"] .as-next-song .as-big { color: var(--text); font-size: clamp(12px, 30cqh, 150px); }
[data-module="ableset"] .as-queued {
align-self: center;
font-size: clamp(8px, 11cqh, 28px);
font-weight: 700;
padding: 0.2em 0.6em;
border-radius: 0.3em;
color: var(--accent-text);
background: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── STATUS ──────────────────────────────────────────────────────── */
[data-module="ableset"] .as-status .as-body { justify-content: flex-start; padding: 8px 10px; }
[data-module="ableset"] .as-rows { display: flex; flex-direction: column; gap: 4px; }
[data-module="ableset"] .as-srow {
--dot: var(--muted);
display: flex;
align-items: center;
gap: 0.55em;
font-size: clamp(11px, 4.5cqw, 18px);
padding: 0.3em 0.5em;
border-radius: 6px;
background: color-mix(in srgb, var(--dot) 10%, transparent);
}
[data-module="ableset"] .as-srow[data-tone="ok"] { --dot: var(--accent); }
[data-module="ableset"] .as-srow[data-tone="warn"] { --dot: var(--warn); }
[data-module="ableset"] .as-srow[data-tone="alert"] { --dot: var(--danger); }
[data-module="ableset"] .as-srow[data-tone=""] { --dot: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="ableset"] .as-srow i { width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--dot); flex-shrink: 0; }
[data-module="ableset"] .as-sk { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
[data-module="ableset"] .as-sv { font-weight: 700; color: var(--dot); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
[data-module="ableset"] .as-srow[data-tone=""] .as-sv { color: var(--text); }
-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.
+197 -22
View File
@@ -1,5 +1,9 @@
/* PCO Plan — client part.
The module presents three tiles and this is the entry for all of them: the
Teams tile (picker entry "teams") is built by teams-client.js and the Song
Key tile ("key") by key-client.js; everything below draws the Plan tile.
The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
streamed. What is shown is this tile's own choice (gear menu →
@@ -8,6 +12,11 @@
(saved with the tile, 14 px until someone presses one); /admin only holds
the connection settings. */
import createTeams from "./teams-client.js";
import createKey from "./key-client.js";
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
import { prettyKey } from "./key-notation.js";
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
@@ -15,6 +24,13 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
// Each picker entry carries its variant and its own settings.
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
return createPlan({ root, moduleApi });
}
function createPlan({ root, moduleApi }) {
let state = null;
let skew = 0; // server clock − local clock, ms
let feedOffline = false;
@@ -22,6 +38,11 @@ export default function create({ root, moduleApi }) {
let ticker = null;
let lastCurrentId = null;
let lastListKey = '';
let absView = null; // what the server says AbleSet is doing, if anything
// The plan this dashboard is looking at — usually the one the server
// follows, otherwise one of the others it also reads.
let view = viewOf(null);
let offViewed = null;
let userScrolledAt = 0;
root.innerHTML = `
@@ -73,6 +94,7 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs();
}
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
@@ -128,7 +150,7 @@ export default function create({ root, moduleApi }) {
/* ── which items to show ────────────────────────────────────────── */
function visibleItems() {
const items = Array.isArray(state?.items) ? state.items : [];
const items = Array.isArray(view.items) ? view.items : [];
return items.filter((it) => {
if (it.itemType === 'header' && !on('showHeaders')) return false;
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
@@ -178,14 +200,14 @@ export default function create({ root, moduleApi }) {
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
function templateValues() {
const plan = state?.plan || {};
const plan = view.plan || {};
const items = state?.items || [];
const planned = plannedLength();
return {
series: on('showSeries') ? plan.seriesTitle || '' : '',
part: on('showPart') ? plan.title || '' : '',
title: on('showPart') ? plan.title || '' : '',
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
servicetype: on('showServiceType') ? view.name || '' : '',
date: on('showDate') ? plan.dates || '' : '',
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
time: on('showServiceTime') && plan.serviceStartsAt
@@ -223,12 +245,12 @@ export default function create({ root, moduleApi }) {
}
function renderHead() {
const plan = state?.plan;
const plan = view.plan;
const show = on('showTopBar') && plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
planTitleEl.textContent = line1 || line2 || view.name || 'Plan';
planSubEl.textContent = line1 ? line2 || '' : '';
planSubEl.hidden = !planSubEl.textContent;
renderClock();
@@ -242,9 +264,12 @@ export default function create({ root, moduleApi }) {
* time once it has ended.
*/
function renderClock() {
const plan = state?.plan;
const live = state?.live || {};
const svc = state?.service || {};
const plan = view.plan;
const live = view.live ? (state?.live || {}) : {};
// The service window belongs to the type being followed. Another type's
// plan must not borrow its clock — it would read as that service running.
// With none, the fall-through below shows that plan's own start time.
const svc = view.live ? (state?.service || {}) : {};
clockLabelEl.textContent = '';
clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value';
@@ -256,7 +281,7 @@ export default function create({ root, moduleApi }) {
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
if (live.following) {
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const done = view.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
const drift = actual - planned;
@@ -305,13 +330,13 @@ export default function create({ root, moduleApi }) {
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
if (!state.reachable && !view.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!state.plan) {
if (!view.plan) {
return {
title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
hint: `${view.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
};
}
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
@@ -354,7 +379,8 @@ export default function create({ root, moduleApi }) {
if (on('showKey') && item.keyName) {
const key = document.createElement('span');
key.className = 'pp-key';
key.textContent = item.keyName;
// B♭, F♯m — the same notation the Song Key tile uses.
key.textContent = prettyKey(item.keyName);
titleLine.appendChild(key);
}
if (on('showNowIndicator')) {
@@ -466,7 +492,7 @@ export default function create({ root, moduleApi }) {
// Rebuild rows only when the plan content or the display settings
// changed; live highlighting and timers are applied in place.
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
const key = JSON.stringify([view.id, view.plan.id, view.items, cfg(), view.live && state.live?.following]);
if (key !== lastListKey) {
lastListKey = key;
listEl.innerHTML = '';
@@ -481,17 +507,147 @@ export default function create({ root, moduleApi }) {
renderLiveMarks();
}
/* ── AbleSet: the song playing under the current item ──────────────
Everything here draws only when the server says there is an AbleSet
module, it is configured and reachable, and the song it is playing is
the plan item on screen. Any of those missing and the tile looks
exactly as it did before AbleSet existed. */
/** The playhead now, in beats, carried forward from the last packet. */
function absBeatsNow() {
if (!absView || absView.beats == null) return null;
if (!absView.playing || !absView.tempo) return absView.beats;
const since = Math.max(0, serverNow() - absView.at) / 1000;
return absView.beats + since * (absView.tempo / 60);
}
function absFraction(part) {
const beats = absBeatsNow();
if (beats == null || !part || part.start == null || part.end == null) return null;
const span = part.end - part.start;
if (!(span > 0)) return null;
return Math.max(0, Math.min(1, (beats - part.start) / span));
}
/** The row AbleSet's song belongs to — only ever the current one. */
function absRow() {
const a = absView;
if (!view.live) return null; // another service type is on screen
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
if (a.itemId !== lastCurrentId) return null;
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
}
/** Keep the playing section visible in the strip — this strip, nothing else. */
function absScrollSections(strip) {
const active = strip.querySelector('.pp-abs-sec.is-now');
if (!active) return;
const box = strip.getBoundingClientRect();
const rect = active.getBoundingClientRect();
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
if (Math.abs(delta) < 1) return;
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
}
function renderAbleSet() {
const row = absRow();
// Anything left on a row that is no longer the one goes.
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
if (!row || !row.contains(stale)) stale.remove();
}
if (!row) return;
if (on('absProgress')) {
const fraction = absFraction(absView.song);
if (fraction == null) {
row.querySelector('.pp-fill')?.remove();
} else {
let fill = row.querySelector('.pp-fill');
if (!fill) {
fill = document.createElement('div');
fill.className = 'pp-fill';
row.prepend(fill); // behind the row's content, which the stylesheet lifts
}
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
}
} else {
row.querySelector('.pp-fill')?.remove();
}
if (on('absSections') && absView.sections.length) {
const main = row.querySelector('.pp-col-main');
let strip = row.querySelector('.pp-abs-sections');
if (!strip && main) {
strip = document.createElement('div');
strip.className = 'pp-abs-sections';
main.appendChild(strip);
}
if (strip) {
// The chips are rebuilt only when the song's sections change. Moving
// on to the next section just moves the classes: rebuilding would
// throw the strip's scroll away thirteen times a song.
const key = absView.sections.map((s) => s.name).join('\u0000');
if (strip.dataset.key !== key) {
strip.dataset.key = key;
strip.dataset.now = '';
strip.innerHTML = '';
for (const section of absView.sections) {
const chip = document.createElement('span');
chip.className = 'pp-abs-sec';
chip.textContent = section.name;
strip.appendChild(chip);
}
}
const now = absView.section?.index ?? -1;
if (strip.dataset.now !== String(now)) {
strip.dataset.now = String(now);
[...strip.children].forEach((chip, i) => {
chip.classList.toggle('is-now', i === now);
chip.classList.toggle('is-done', i < now);
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
});
requestAnimationFrame(() => absScrollSections(strip));
}
const active = strip.children[now];
if (active) {
const fraction = absFraction(absView.section);
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
}
}
} else {
row.querySelector('.pp-abs-sections')?.remove();
}
}
/**
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
* scroll every ancestor that can scroll with it, which on a dashboard taller
* than the window drags the whole board; moving `listEl.scrollTop` moves
* this tile and nothing else. Measured from rectangles rather than
* `offsetTop` because the rows' offset parent is the tile, not the list.
*/
function keepInView(row) {
const list = listEl.getBoundingClientRect();
const rect = row.getBoundingClientRect();
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
if (Math.abs(delta) < 1) return;
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
}
/** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() {
const live = state?.live || {};
// Only the followed service type has a live item: this dashboard may be
// looking at another one, which has no ProPresenter and no clock.
const live = view.live ? (state?.live || {}) : {};
const now = serverNow();
const currentId = live.following ? live.currentItemId || '' : '';
const history = live.history || {};
const byId = new Map(state.items.map((it) => [it.id, it]));
const byId = new Map(view.items.map((it) => [it.id, it]));
// Everything before the current item counts as done, matched or not.
const doneIds = new Set();
if (currentId) {
for (const it of state.items) {
for (const it of view.items) {
if (it.id === currentId) break;
if (it.itemType !== 'header') doneIds.add(it.id);
}
@@ -519,11 +675,12 @@ export default function create({ root, moduleApi }) {
}
if (!h.endedAt) elapsedEl.classList.add('is-running');
}
renderAbleSet();
if (currentId !== lastCurrentId) {
lastCurrentId = currentId;
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
if (row) keepInView(row);
}
}
}
@@ -531,8 +688,8 @@ export default function create({ root, moduleApi }) {
/* ── footer: what ProPresenter is doing ─────────────────────────── */
function renderFoot() {
const live = state?.live;
const show = on('showLiveFooter') && live?.following && state?.plan;
const live = view.live ? state?.live : null;
const show = on('showLiveFooter') && live?.following && view.plan;
footEl.hidden = !show;
footEl.className = 'pp-foot';
if (!show) return;
@@ -568,7 +725,10 @@ export default function create({ root, moduleApi }) {
/* ── render + ticking ───────────────────────────────────────────── */
function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
const color = (value, fallback) => {
@@ -598,9 +758,10 @@ export default function create({ root, moduleApi }) {
}
function tick() {
if (!state?.plan) return;
renderAbleSet();
if (!view.plan) return;
renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks();
}
/* ── live feed ──────────────────────────────────────────────────── */
@@ -609,6 +770,7 @@ export default function create({ root, moduleApi }) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
if (payload.ableset) absView = payload.ableset;
feedOffline = false;
render();
}
@@ -629,6 +791,16 @@ export default function create({ root, moduleApi }) {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
// Its own event, several a second while a song plays: redraw that one
// row rather than the whole list.
ableset(ev) {
try {
absView = JSON.parse(ev.data);
} catch {
return;
}
if (state) renderAbleSet();
},
},
});
}
@@ -648,11 +820,14 @@ export default function create({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(() => { lastListKey = ''; render(); });
listEl.addEventListener('wheel', onUserScroll, { passive: true });
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
ticker = setInterval(tick, 1000);
},
stop() {
offViewed?.();
offViewed = null;
clearInterval(ticker);
ticker = null;
stream?.close();
+197
View File
@@ -0,0 +1,197 @@
/* PCO Plan — the Song Key tile.
One thing, big: the key of the plan item on screen. It reads the same
streamed state the Plan tile does, so it needs no connection of its own;
which item is current comes from the ProPresenter module, as ever. */
import { parseKey, prettyKey } from "./key-notation.js";
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
export default function createKey({ root, moduleApi }) {
let state = null;
let stream = null;
let feedOffline = false;
let view = viewOf(null);
let offViewed = null;
root.innerHTML = `
<div class="pk-wrap">
<div class="pk-key"></div>
<div class="pk-name"></div>
<div class="pk-next"></div>
</div>`;
const wrap = root.querySelector('.pk-wrap');
const keyEl = root.querySelector('.pk-key');
const nameEl = root.querySelector('.pk-name');
const nextEl = root.querySelector('.pk-next');
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── the item on screen, and the one after it ───────────────────── */
function items() {
return Array.isArray(view.items) ? view.items : [];
}
function currentItem() {
// Only the followed service type has a live item; on another one there
// is nothing current, so the tile says so rather than guessing.
const id = view.live && state?.live?.following ? state.live.currentItemId : '';
return id ? items().find((it) => it.id === id) || null : null;
}
/** The next item that actually has a key — a talk between two songs is skipped. */
function nextKeyed(after) {
const list = items();
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
if (from <= 0) return null;
for (let i = from; i < list.length; i += 1) {
if (list[i].keyName) return list[i];
}
return null;
}
function titleOf(item) {
return item ? (item.song?.title || item.title || '') : '';
}
/* ── render ─────────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
const item = currentItem();
if (!view.live) return moduleApi.setStatus('ok', `${view.name || 'Another service type'} — not the one live`);
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
// The key on screen stays — the band still needs it — but a tile must
// never look confidently live while the feed that chose it is gone.
if (!state.live.available) {
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
}
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
}
/**
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
* better with the accidental raised and the minor kept small.
*/
function drawKey(text) {
keyEl.innerHTML = '';
const parts = parseKey(text);
if (!parts) {
keyEl.textContent = text;
return;
}
keyEl.appendChild(document.createTextNode(parts.note));
if (parts.accidental) {
const acc = document.createElement('span');
acc.className = 'pk-accidental';
acc.textContent = parts.accidental;
keyEl.appendChild(acc);
}
if (parts.quality) {
const tail = document.createElement('span');
tail.className = 'pk-quality';
tail.textContent = parts.quality;
keyEl.appendChild(tail);
}
}
function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
const live = state?.live;
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
const color = String(cfg().keyColor || '').trim();
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
renderStatus();
const item = currentItem();
const key = item?.keyName || '';
wrap.classList.toggle('has-key', Boolean(key));
if (!state || !state.configured || !state.serviceTypeSet) {
drawKey('—');
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
nextEl.textContent = '';
return;
}
if (!item) {
drawKey('—');
nameEl.textContent = !view.live
? (view.name || 'Another service type')
: (state.live?.following ? 'Nothing live' : 'Not following ProPresenter');
nextEl.textContent = '';
return;
}
drawKey(key || '—');
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
nameEl.hidden = !nameEl.textContent;
const next = on('showNextKey') ? nextKeyed(item) : null;
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
nextEl.hidden = !nextEl.textContent;
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
feedOffline = false;
render();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { feedOffline = false; render(); },
error() { feedOffline = true; render(); },
events: {
state(ev) {
try {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(render);
render();
},
stop() {
offViewed?.();
offViewed = null;
stream?.close();
stream = null;
root.innerHTML = '';
},
onConfigChange() {
// Admin holds the connection only; the server re-inits and re-sends.
},
};
}
+25
View File
@@ -0,0 +1,25 @@
/* PCO Plan — how a song key is written.
Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this
module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge
on an item and the Song Key tile all agree. Shared rather than repeated
because the day one of them changes, they all should. */
/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */
export function parseKey(raw) {
const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim());
if (!match) return null;
const [, note, accidental, quality] = match;
return {
note,
accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental,
quality: quality.trim(),
};
}
/** The same thing as one string, for a badge or a caption. */
export function prettyKey(raw) {
const parts = parseKey(raw);
if (!parts) return String(raw || '').trim();
return `${parts.note}${parts.accidental}${parts.quality}`;
}
+282 -10
View File
@@ -1,9 +1,9 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.6.0",
"proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services",
"version": "1.10.0",
"proddash": ">=1.12.0",
"description": "Order of service, team assignments and the song key from Planning Center Services",
"provides": [
"timers"
],
@@ -46,11 +46,13 @@
"default": "",
"group": "Planning Center credentials"
},
"serviceTypeId": {
"type": "select",
"label": "Service type",
"default": "",
"optionsRoute": "/service-types"
"serviceTypes": {
"type": "multiselect",
"label": "Service types",
"orderable": true,
"optionsRoute": "/service-types",
"help": "Add every service type whose plans this ProdDash should follow. Order is priority: when two of them have a plan at the same date and time, the one higher up wins.",
"addLabel": "Add a service type…"
},
"planId": {
"type": "select",
@@ -63,7 +65,8 @@
}
],
"optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId"
"optionsDependsOn": "serviceTypes",
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
},
"pollSeconds": {
"type": "number",
@@ -382,6 +385,275 @@
"label": "Match line",
"default": true,
"group": "Show"
},
"absProgress": {
"type": "boolean",
"label": "Song progress",
"default": true,
"group": "Show"
},
"absSections": {
"type": "boolean",
"label": "Song sections",
"default": true,
"group": "Show"
}
}
},
"tiles": [
{
"id": "plan",
"name": "Plan",
"description": "The order of service, item by item",
"defaultSize": {
"w": 5,
"h": 6
},
"minSize": {
"w": 3,
"h": 3
}
},
{
"id": "teams",
"name": "Teams",
"description": "Who is scheduled, by team and position",
"defaultSize": {
"w": 4,
"h": 6
},
"minSize": {
"w": 2,
"h": 2
},
"instanceSchema": {
"columns": {
"type": "select",
"label": "Columns",
"default": "auto",
"options": [
{
"value": "auto",
"label": "Fit the tile"
},
{
"value": "1",
"label": "One"
},
{
"value": "2",
"label": "Two"
},
{
"value": "3",
"label": "Three"
}
]
},
"teams": {
"type": "multiselect",
"label": "Teams",
"columns": 2,
"allByDefault": true,
"optionsRoute": "/teams"
},
"headerTemplate": {
"type": "template",
"label": "Top bar text",
"default": "{serviceType} • {date} | {people} on {teams}",
"group": "Top bar",
"variables": [
{
"value": "{series}",
"label": "Series"
},
{
"value": "{part}",
"label": "Part / plan title"
},
{
"value": "{serviceType}",
"label": "Service type"
},
{
"value": "{date}",
"label": "Date"
},
{
"value": "{shortDate}",
"label": "Short date"
},
{
"value": "{time}",
"label": "Service time"
},
{
"value": "{people}",
"label": "People count"
},
{
"value": "{teams}",
"label": "Team count"
},
{
"value": "{confirmed}",
"label": "Confirmed count"
}
],
"help": "Use • between items and | to start a second line."
},
"teamColor": {
"type": "color",
"label": "Team headers",
"default": "#2ee59a",
"group": "Colors"
},
"tintTeams": {
"type": "boolean",
"label": "Tint teams",
"default": false,
"group": "Colors"
},
"tintRows": {
"type": "boolean",
"label": "Tint rows too",
"default": false,
"group": "Colors",
"showWhen": "tintTeams"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"confirmedColor": {
"type": "color",
"label": "Confirmed",
"default": "#2ee59a",
"group": "Colors"
},
"unconfirmedColor": {
"type": "color",
"label": "Unconfirmed",
"default": "#f0b429",
"group": "Colors"
},
"declinedColor": {
"type": "color",
"label": "Declined",
"default": "#f4433c",
"group": "Colors"
},
"showTopBar": {
"type": "boolean",
"label": "Top bar",
"default": true,
"group": "Show"
},
"showTeamHeaders": {
"type": "boolean",
"label": "Team headers",
"default": true,
"group": "Show"
},
"showTeamCounts": {
"type": "boolean",
"label": "Team counts",
"default": true,
"group": "Show"
},
"showAvatars": {
"type": "boolean",
"label": "Avatars",
"default": true,
"group": "Show"
},
"showPosition": {
"type": "boolean",
"label": "Positions",
"default": true,
"group": "Show"
},
"groupByPosition": {
"type": "boolean",
"label": "Group by position",
"default": false,
"group": "Show"
},
"showStatus": {
"type": "boolean",
"label": "Status",
"default": true,
"group": "Show"
},
"showNotes": {
"type": "boolean",
"label": "Person notes",
"default": false,
"group": "Show"
},
"showDeclined": {
"type": "boolean",
"label": "Declined people",
"default": true,
"group": "Show"
},
"showUnconfirmed": {
"type": "boolean",
"label": "Unconfirmed people",
"default": true,
"group": "Show"
},
"showEmptyTeams": {
"type": "boolean",
"label": "Teams with nobody",
"default": false,
"group": "Show"
}
},
"instanceGroups": {
"Top bar": {
"collapsed": false
},
"Colors": {
"collapsed": false,
"columns": 2
},
"Show": {
"collapsed": true,
"columns": 2
}
}
},
{
"id": "key",
"name": "Song Key",
"description": "The key of the item on screen, big",
"defaultSize": {
"w": 2,
"h": 2
},
"minSize": {
"w": 1,
"h": 1
},
"instanceSchema": {
"keyColor": {
"type": "color",
"label": "Key",
"default": "#4ea1ff"
},
"showSongName": {
"type": "boolean",
"label": "Song name",
"default": true
},
"showNextKey": {
"type": "boolean",
"label": "Next key",
"default": true
}
}
}
]
}
File diff suppressed because it is too large Load Diff
+428 -1
View File
@@ -86,6 +86,7 @@
}
[data-module="pco-plan"] .pp-row {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.6em;
@@ -205,7 +206,9 @@
[data-module="pco-plan"] .pp-now-tag {
display: none;
padding: 0.05em 0.45em;
/* Optically centred like the section chips: uppercase has no descenders,
so the capitals sat 0.59px low in the pill. */
padding: 0.018em 0.45em 0.082em;
border-radius: 999px;
background: var(--pp-current);
color: var(--bg);
@@ -365,3 +368,427 @@
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
/* ── the Teams tile ───────────────────────────────────────────────────
Who is scheduled, by team. Sizes hang off --pp-size like the Plan tile;
colours come from the tile's own settings (--tm-team per section,
--tm-status per person) over the shell's palette. */
[data-module="pco-plan"] .tm-wrap {
--pp-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
font-size: var(--pp-size);
color: var(--text);
}
[data-module="pco-plan"] .tm-head {
flex: none;
padding: 0.6em 0.85em 0.5em;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
[data-module="pco-plan"] .tm-head[hidden] { display: none; }
[data-module="pco-plan"] .tm-title {
font-size: 1.1em;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-sub {
margin-top: 0.15em;
font-size: 0.82em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-sub[hidden] { display: none; }
[data-module="pco-plan"] .tm-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 0.6em;
overscroll-behavior: contain;
}
/* ── a team ── */
[data-module="pco-plan"] .tm-team { --tm-team: var(--accent); }
/* The header band runs edge to edge, like the Plan tile's section headers. */
[data-module="pco-plan"] .tm-team-head {
display: flex;
align-items: center;
gap: 0.6em;
padding: 0.45em 0.9em 0.4em;
margin-top: 0.7em;
background: color-mix(in srgb, var(--tm-team) 22%, transparent);
}
[data-module="pco-plan"] .tm-team:first-child .tm-team-head { margin-top: 0; }
[data-module="pco-plan"] .header-gradient .tm-team-head {
background: linear-gradient(90deg,
color-mix(in srgb, var(--tm-team) 30%, transparent),
color-mix(in srgb, var(--tm-team) 6%, transparent) 70%,
transparent);
}
[data-module="pco-plan"] .tm-team-name {
flex: 1;
min-width: 0;
font-size: 0.85em;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-team-count {
flex: none;
font-size: 0.78em;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
[data-module="pco-plan"] .tm-people {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
gap: 0.15em 0.5em;
padding: 0.35em 0.6em 0.2em;
}
[data-module="pco-plan"] .tm-people.is-fixed-cols {
grid-template-columns: repeat(var(--tm-cols, 1), minmax(0, 1fr));
}
[data-module="pco-plan"] .tm-people.is-sub { padding-top: 0.1em; padding-bottom: 0.1em; }
/* Grouped by position: headings span, each position's people form their own grid. */
[data-module="pco-plan"] .tm-groups { padding-bottom: 0.2em; }
[data-module="pco-plan"] .tm-position-head {
padding: 0.4em 0.9em 0;
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="pco-plan"] .tm-team-empty {
grid-column: 1 / -1;
padding: 0.2em 0.3em 0.4em;
font-size: 0.85em;
font-style: italic;
color: var(--muted);
}
/* ── one person ── */
[data-module="pco-plan"] .tm-person {
display: flex;
align-items: center;
gap: 0.55em;
min-width: 0;
padding: 0.3em 0.4em;
border-radius: 0.45em;
}
[data-module="pco-plan"] .tm-person:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="pco-plan"] .tint-rows .tm-person { background: color-mix(in srgb, var(--tm-team) 8%, transparent); }
[data-module="pco-plan"] .tint-rows .tm-person:hover { background: color-mix(in srgb, var(--tm-team) 16%, transparent); }
[data-module="pco-plan"] .tm-person.is-declined { opacity: 0.55; }
[data-module="pco-plan"] .tm-avatar {
position: relative;
flex: none;
width: 2.1em;
height: 2.1em;
border-radius: 999px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--tm-team) 28%, var(--panel));
color: var(--text);
font-size: 0.78em;
font-weight: 800;
letter-spacing: 0.02em;
user-select: none;
}
/* The photo covers the initials; if it fails to load the script drops it. */
[data-module="pco-plan"] .tm-avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--panel);
}
[data-module="pco-plan"] .tm-person-main { flex: 1; min-width: 0; }
[data-module="pco-plan"] .tm-name {
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-person.is-declined .tm-name { text-decoration: line-through; }
[data-module="pco-plan"] .tm-position {
font-size: 0.8em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-note {
margin-top: 0.1em;
font-size: 0.78em;
line-height: 1.3;
color: color-mix(in srgb, var(--text) 75%, var(--muted));
overflow-wrap: anywhere;
}
[data-module="pco-plan"] .tm-status {
flex: none;
width: 0.55em;
height: 0.55em;
border-radius: 999px;
background: var(--tm-status, var(--muted));
box-shadow: 0 0 0 0.18em color-mix(in srgb, var(--tm-status, var(--muted)) 18%, transparent);
}
/* ── empty / degraded ── */
[data-module="pco-plan"] .tm-empty {
padding: 12% 1.2em 1em;
text-align: center;
color: var(--muted);
}
[data-module="pco-plan"] .tm-empty-title { font-size: 1.1em; font-weight: 700; color: var(--text); }
[data-module="pco-plan"] .tm-empty-hint { margin-top: 0.4em; font-size: 0.9em; line-height: 1.4; }
[data-module="pco-plan"] .tm-empty-link {
display: inline-block;
margin-top: 0.9em;
padding: 0.35em 0.9em;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 999px;
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
[data-module="pco-plan"] .tm-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
/* ── AbleSet under the current item ───────────────────────────────────
Drawn only while an AbleSet module is configured, reachable, and playing
the song this item is. The fill is a layer behind the row's own content;
everything else in the row is lifted over it. */
[data-module="pco-plan"] .pp-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 0;
border-radius: 0.5em 0 0 0.5em;
background: color-mix(in srgb, var(--pp-current) 26%, transparent);
/* Packets arrive about five times a second; the glide covers the gaps. */
transition: width 0.22s linear;
pointer-events: none;
}
[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; }
/* The sections strip, bottom right of the current item. */
[data-module="pco-plan"] .pp-abs-sections {
display: flex;
gap: 0.3em;
/* Bottom right of the item: hug the content and sit against the right edge
until there are more sections than fit, when it fills the width and
scrolls instead. */
width: fit-content;
max-width: 100%;
margin-top: 0.35em;
margin-left: auto;
padding-bottom: 0.1em;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
overscroll-behavior: contain;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
}
[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; }
[data-module="pco-plan"] .pp-abs-sec {
flex: none;
/* Uppercase text has no descenders, so the font's descent allowance below
the baseline leaves the caps sitting high in the box — measured at 0.9px
too much headroom. The padding is a touch shallower on top and deeper
below to centre the letters themselves; in em, so it holds at any text
size. */
padding: 0.095em 0.55em 0.185em;
/* The same rounded square, tinted ground and transparent border the AbleSet
module gives its own section pills, so the two read as one family. */
border-radius: 0.35em;
border: 1px solid transparent;
background: color-mix(in srgb, var(--text) 8%, var(--panel));
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted);
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
/* The section now playing fills left to right as it goes, like the row. */
/* The section playing keeps its own ground and fills across it, so the
progress reads against the chip rather than against the row behind it. */
[data-module="pco-plan"] .pp-abs-sec.is-now {
color: var(--text);
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
background:
linear-gradient(90deg,
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
}
/* ── the Song Key tile ────────────────────────────────────────────────
One value, as large as the tile allows. Sizes come from the tile itself
(container units), like the AbleSet module's single readouts, so a small
tile and a wall-sized one both read properly. */
[data-module="pco-plan"] .pk-wrap {
--pk-color: #4ea1ff;
position: absolute;
inset: 0;
container-type: size;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2em;
padding: 4%;
text-align: center;
user-select: none;
}
[data-module="pco-plan"] .pk-key {
font-size: clamp(18px, 46cqmin, 260px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.01em;
color: var(--muted);
white-space: nowrap;
}
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
/* A key is a note, but it has to read as one word at a glance from the back
of a room: the accidental and the quality sit on the same line as the
letter, only smaller. Raising the accidental looked like two stacked
characters at tile sizes. */
[data-module="pco-plan"] .pk-accidental {
font-size: 0.68em;
margin-left: 0.01em;
}
[data-module="pco-plan"] .pk-quality {
font-size: 0.5em;
font-weight: 700;
margin-left: 0.05em;
opacity: 0.85;
}
[data-module="pco-plan"] .pk-name {
max-width: 100%;
font-size: clamp(9px, 9cqmin, 30px);
font-weight: 600;
line-height: 1.2;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
[data-module="pco-plan"] .pk-next {
max-width: 100%;
font-size: clamp(8px, 6.5cqmin, 20px);
font-weight: 600;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
/* The feed that picks the item is gone: the key stays readable, but faded,
so nobody trusts it as current without looking at the status dot. */
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
/* ── the service-type menu, and a tile looking at another one ────────── */
[data-module="pco-plan"] .pp-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 9px;
background: none;
border: none;
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-family: inherit;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
[data-module="pco-plan"] .pp-menu-item:hover { background: var(--border); }
[data-module="pco-plan"] .pp-menu-tick {
width: 12px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="pco-plan"] .pp-menu-item.is-on .pp-menu-tick { visibility: visible; }
[data-module="pco-plan"] .pp-menu-name { flex: 1; min-width: 0; font-weight: 600; }
[data-module="pco-plan"] .pp-menu-note {
flex: none;
font-size: 0.8em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
/* Looking at a service type other than the one running: the top bar carries
a quiet stripe so nobody reads this as the live plan. */
[data-module="pco-plan"] .pp-wrap.is-other-type .pp-head,
[data-module="pco-plan"] .tm-wrap.is-other-type .tm-head {
background: color-mix(in srgb, var(--warn) 9%, transparent);
border-bottom-color: color-mix(in srgb, var(--warn) 45%, transparent);
}
[data-module="pco-plan"] .pk-wrap.is-other-type {
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--warn) 45%, transparent);
}
+447
View File
@@ -0,0 +1,447 @@
/* PCO Plan — the Teams tile.
Who is scheduled on the current plan, by team and position, from the same
server state the Plan tile draws (state.teams). Everything shown is this
tile's own choice (gear menu): which teams and positions, what each row
carries, and the colours. Photos come from ProdDash's own avatar route —
the browser never talks to Planning Center. */
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
export default function createTeams({ root, moduleApi }) {
let state = null;
let feedOffline = false;
let stream = null;
let lastKey = '';
let view = viewOf(null);
let offViewed = null;
root.innerHTML = `
<div class="tm-wrap">
<header class="tm-head">
<div class="tm-title"></div>
<div class="tm-sub"></div>
</header>
<div class="tm-body"></div>
</div>`;
const wrap = root.querySelector('.tm-wrap');
const headEl = root.querySelector('.tm-head');
const titleEl = root.querySelector('.tm-title');
const subEl = root.querySelector('.tm-sub');
const bodyEl = root.querySelector('.tm-body');
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── title-bar controls ─────────────────────────────────────────── */
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read from Planning Center',
onClick() {
refreshBtn.classList.add('active');
moduleApi.fetch('/refresh', { method: 'POST' })
.catch(() => { /* the stream reports the outcome */ })
.finally(() => refreshBtn.classList.remove('active'));
},
});
/* ── helpers ────────────────────────────────────────────────────── */
function colorOf(value, fallback) {
const s = String(value || '').trim();
return /^#[0-9a-fA-F]{3,8}$/.test(s) ? s : fallback;
}
/**
* A team's colour when "Tint teams" is on. Taken from the team's position
* in the plan, not a hash of its name: neighbouring teams then always look
* different, which is the whole point of tinting them.
*/
function teamColor(index) {
if (!cfg().tintTeams) return colorOf(cfg().teamColor, '#2ee59a');
return `hsl(${TEAM_HUES[index % TEAM_HUES.length]} 70% 55%)`;
}
function statusColor(status) {
if (status === 'confirmed') return colorOf(cfg().confirmedColor, '#2ee59a');
if (status === 'declined') return colorOf(cfg().declinedColor, '#f4433c');
return colorOf(cfg().unconfirmedColor, '#f0b429');
}
const STATUS_LABEL = { confirmed: 'Confirmed', unconfirmed: 'Unconfirmed', declined: 'Declined' };
/** "Sarah Miles" → "SM"; one word → its first two letters. */
function initialsOf(name) {
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!words.length) return '?';
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
/** A saved multiselect list, or null when the tile has never chosen (= all). */
function chosen(key) {
const v = cfg()[key];
return Array.isArray(v) ? v : null;
}
/* ── which teams and people to show ─────────────────────────────── */
function visibleTeams() {
const teams = Array.isArray(view.teams) ? view.teams : [];
const wantTeams = chosen('teams');
const out = [];
for (const team of teams) {
if (wantTeams && !wantTeams.includes(team.id)) continue;
const members = team.members.filter((m) => {
if (m.status === 'declined' && !on('showDeclined')) return false;
if (m.status === 'unconfirmed' && !on('showUnconfirmed')) return false;
return true;
});
if (!members.length && !cfg().showEmptyTeams) continue;
out.push({ ...team, members });
}
return out;
}
/* ── status dot ─────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
}
/* ── top bar ────────────────────────────────────────────────────── */
/** The counts describe what this tile shows, not what the plan holds. */
function templateValues() {
const plan = view.plan || {};
const teams = visibleTeams();
const people = teams.reduce((n, t) => n + t.members.length, 0);
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
return {
series: plan.seriesTitle || '',
part: plan.title || '',
servicetype: view.name || '',
date: plan.dates || '',
shortdate: plan.shortDates || plan.dates || '',
time: plan.serviceStartsAt
? new Date(plan.serviceStartsAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: '',
people: people ? `${people} ${people === 1 ? 'person' : 'people'}` : '',
teams: teams.length ? `${teams.length} ${teams.length === 1 ? 'team' : 'teams'}` : '',
confirmed: confirmed ? `${confirmed} confirmed` : '',
};
}
/**
* "{serviceType} • {date} | {people} on {teams}" → up to two lines. Within
* a line • separates segments and a segment whose placeholders all came out
* empty is dropped, so a plan with no series never leaves a stray bullet.
*/
function renderTemplate(template, values) {
return String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2).map((line) => {
const kept = [];
for (const seg of line.split(/\s*[•·]\s*/)) {
let sawVar = false;
let allEmpty = true;
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
sawVar = true;
const v = values[name.toLowerCase()];
if (v) allEmpty = false;
return v || '';
}).trim();
if (sawVar && allEmpty) continue;
if (text) kept.push(text);
}
return kept.join(' • ');
});
}
function renderHead() {
const show = on('showTopBar') && view.plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
titleEl.textContent = line1 || line2 || view.name || 'Teams';
subEl.textContent = line1 ? line2 || '' : '';
subEl.hidden = !subEl.textContent;
}
/* ── the list ───────────────────────────────────────────────────── */
function emptyMessage() {
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
if (!state) return { title: 'Connecting…', hint: '' };
if (!state.configured) {
return {
title: 'Planning Center not connected',
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.serviceTypeSet) {
return {
title: 'No service type selected',
hint: 'Pick the service type to follow under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !view.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!view.plan) {
return {
title: 'No plan found',
hint: `${view.name || 'This service type'} has no upcoming or recent plans.`,
};
}
if (!(view.teams || []).length) {
return {
title: 'Nobody scheduled yet',
hint: view.teamsError || 'This plan has no team members in Planning Center.',
};
}
if (!visibleTeams().length) {
return { title: 'Nothing to show', hint: 'Every team is hidden by this tile’s settings (gear menu).' };
}
return null;
}
function buildMember(member) {
const row = document.createElement('div');
row.className = `tm-person is-${member.status}`;
if (on('showAvatars')) {
const avatar = document.createElement('div');
avatar.className = 'tm-avatar';
avatar.textContent = initialsOf(member.name);
if (member.hasPhoto) {
const img = document.createElement('img');
img.loading = 'lazy';
img.alt = '';
img.src = `/api/modules/${moduleApi.id}/team-avatar/${encodeURIComponent(member.id)}`;
// No photo, or the CDN is unreachable: the initials underneath stand.
img.addEventListener('error', () => img.remove());
avatar.appendChild(img);
}
row.appendChild(avatar);
}
const main = document.createElement('div');
main.className = 'tm-person-main';
const nameEl = document.createElement('div');
nameEl.className = 'tm-name';
nameEl.textContent = member.name;
main.appendChild(nameEl);
if (on('showPosition') && member.position && !cfg().groupByPosition) {
const pos = document.createElement('div');
pos.className = 'tm-position';
pos.textContent = member.position;
main.appendChild(pos);
}
if (cfg().showNotes && (member.notes || member.declineReason)) {
const note = document.createElement('div');
note.className = 'tm-note';
note.textContent = member.declineReason ? `Declined: ${member.declineReason}` : member.notes;
main.appendChild(note);
}
row.appendChild(main);
if (on('showStatus')) {
const status = document.createElement('div');
status.className = 'tm-status';
status.style.setProperty('--tm-status', statusColor(member.status));
status.title = STATUS_LABEL[member.status] || member.status;
row.appendChild(status);
}
return row;
}
/** One team: its header band, then its people (optionally under position sub-headings). */
function buildTeam(team, index) {
const section = document.createElement('section');
section.className = 'tm-team';
section.style.setProperty('--tm-team', teamColor(index));
if (on('showTeamHeaders')) {
const header = document.createElement('div');
header.className = 'tm-team-head';
const name = document.createElement('span');
name.className = 'tm-team-name';
name.textContent = team.name;
header.appendChild(name);
if (on('showTeamCounts')) {
const counts = document.createElement('span');
counts.className = 'tm-team-count';
const pending = team.counts.unconfirmed;
counts.textContent = pending ? `${team.members.length} · ${pending} pending` : String(team.members.length);
header.appendChild(counts);
}
section.appendChild(header);
}
const columns = String(cfg().columns || 'auto');
const grouping = Boolean(cfg().groupByPosition) && on('showPosition');
// Grouped, the container is a plain block so each position heading spans
// the width and only its own people form a grid.
const list = document.createElement('div');
list.className = grouping ? 'tm-groups' : 'tm-people';
if (!grouping && columns !== 'auto') {
list.style.setProperty('--tm-cols', columns);
list.classList.add('is-fixed-cols');
}
if (grouping) {
// Positions keep the order the members already sort in.
const groups = new Map();
for (const m of team.members) {
const key = m.position || '(no position)';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(m);
}
for (const [position, members] of groups) {
const label = document.createElement('div');
label.className = 'tm-position-head';
label.textContent = position;
list.appendChild(label);
const sub = document.createElement('div');
sub.className = 'tm-people is-sub';
if (columns !== 'auto') sub.style.setProperty('--tm-cols', columns);
sub.classList.toggle('is-fixed-cols', columns !== 'auto');
for (const m of members) sub.appendChild(buildMember(m));
list.appendChild(sub);
}
} else {
for (const m of team.members) list.appendChild(buildMember(m));
}
if (!team.members.length) {
const none = document.createElement('div');
none.className = 'tm-team-empty';
none.textContent = 'Nobody scheduled';
list.appendChild(none);
}
section.appendChild(list);
return section;
}
function renderBody() {
const empty = emptyMessage();
if (empty) {
lastKey = '';
bodyEl.innerHTML = '';
const box = document.createElement('div');
box.className = 'tm-empty';
const t = document.createElement('div');
t.className = 'tm-empty-title';
t.textContent = empty.title;
box.appendChild(t);
if (empty.hint) {
const h = document.createElement('div');
h.className = 'tm-empty-hint';
h.textContent = empty.hint;
box.appendChild(h);
}
if (empty.link) {
const a = document.createElement('a');
a.className = 'tm-empty-link';
a.href = empty.link.href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = empty.link.text;
box.appendChild(a);
}
bodyEl.appendChild(box);
return;
}
// Rebuild only when the roster or the settings actually changed: the
// avatars are <img>s and a needless rebuild makes them flicker.
const teams = visibleTeams();
const key = JSON.stringify([view.id, teams, cfg()]);
if (key === lastKey) return;
lastKey = key;
bodyEl.innerHTML = '';
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
}
function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
wrap.classList.toggle('header-gradient', on('headerGradient'));
renderStatus();
renderHead();
renderBody();
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
feedOffline = false;
render();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { feedOffline = false; render(); },
error() { feedOffline = true; render(); },
events: {
state(ev) {
try {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(() => { lastKey = ''; render(); });
render();
},
stop() {
offViewed?.();
offViewed = null;
stream?.close();
stream = null;
root.innerHTML = '';
},
onConfigChange() {
// Admin holds the connection only; the server re-inits and re-sends.
},
};
}
+171
View File
@@ -0,0 +1,171 @@
/* PCO Plan — which service type THIS dashboard is looking at.
The module's server follows one service type at a time: whichever the
admin's priority order lands on. A dashboard may want a different one on
screen — the other campus, the youth plan — without changing what the
server follows or what any other screen sees.
That choice belongs to the browser, so it lives here: one value for the
whole page, kept in localStorage and shared between every PCO Plan tile on
it, so switching from one tile moves them all together. */
const KEY = 'pco-plan:viewed-service-type';
/** One bus per page, however many tiles import this. */
function bus() {
if (!window.__pcoPlanViewed) {
let value = '';
try {
value = String(localStorage.getItem(KEY) || '');
} catch {
value = ''; // private window, storage off: the choice just won't persist
}
window.__pcoPlanViewed = { value, listeners: new Set() };
}
return window.__pcoPlanViewed;
}
/** '' means "whatever the server is following" — the normal case. */
export function viewedType() {
return bus().value;
}
export function setViewedType(id) {
const b = bus();
const next = String(id || '');
if (next === b.value) return;
b.value = next;
try {
if (next) localStorage.setItem(KEY, next);
else localStorage.removeItem(KEY);
} catch { /* not persisted, still shared for this page */ }
for (const fn of [...b.listeners]) {
try {
fn(next);
} catch (err) {
console.error('[pco-plan] viewed-type listener failed:', err);
}
}
}
/** Returns an unsubscribe — call it from the tile's stop(). */
export function onViewedType(fn) {
const b = bus();
b.listeners.add(fn);
return () => b.listeners.delete(fn);
}
/**
* The plan a tile should draw, given the whole state and this page's choice:
* the one the server follows, or one of the others it also reads.
*
* `live` is false for anything but the followed one — the ProPresenter match,
* the service clock and the timers belong to the service actually running, so
* a tile looking elsewhere must not pretend to have them.
*/
export function viewOf(state) {
const empty = { id: '', name: '', plan: null, items: [], teams: [], teamsError: '', live: true, lastError: '' };
if (!state) return empty;
const wanted = viewedType();
const activeId = state.serviceType?.id || '';
if (!wanted || wanted === activeId) {
return {
id: activeId,
name: state.serviceType?.name || '',
plan: state.plan,
items: state.items || [],
teams: state.teams || [],
teamsError: state.teamsError || '',
live: true,
lastError: '',
};
}
const other = (state.others || {})[wanted];
if (!other) {
// Ticked off in admin since this dashboard chose it, or not read yet.
return { ...empty, id: wanted, live: false, lastError: 'That service type is no longer selected' };
}
return {
id: other.id,
name: other.name,
plan: other.plan,
items: other.items || [],
teams: other.teams || [],
teamsError: other.teamsError || '',
live: false,
lastError: other.lastError || '',
};
}
/** Every service type a dashboard may switch to, in the admin's priority order. */
export function choices(state) {
const out = [];
const activeId = state?.serviceType?.id || '';
for (const id of state?.serviceTypes || []) {
if (id === activeId) out.push({ id, name: state.serviceType?.name || id, following: true });
else out.push({ id, name: (state.others || {})[id]?.name || id, following: false });
}
return out;
}
const SWITCH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h13l-3-3"/><path d="M20 16H7l3 3"/></svg>';
/**
* The title-bar control that switches this dashboard between the selected
* service types. Added by every PCO Plan tile, because switching from any of
* them moves all of them; it hides itself whenever there is only one service
* type to choose from, which is the usual case.
*
* Returns { sync } — call it when the state arrives so the control appears,
* disappears or re-labels itself.
*/
export function addServiceTypeSwitch({ moduleApi, getState }) {
const menu = moduleApi.header.addMenu({
icon: SWITCH_SVG,
title: 'Service type shown on this dashboard',
build(el) {
el.classList.add('pp-menu');
const state = getState();
const wanted = viewedType();
const rows = [
{ id: '', name: 'Whatever is live', following: false, auto: true },
...choices(state),
];
for (const row of rows) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'pp-menu-item' + (row.id === wanted ? ' is-on' : '');
const tick = document.createElement('span');
tick.className = 'pp-menu-tick';
tick.textContent = '✓';
const name = document.createElement('span');
name.className = 'pp-menu-name';
name.textContent = row.name;
item.append(tick, name);
if (row.following) {
const note = document.createElement('span');
note.className = 'pp-menu-note';
note.textContent = 'live';
item.appendChild(note);
}
item.addEventListener('click', () => {
setViewedType(row.id);
menu.close();
});
el.appendChild(item);
}
},
});
return {
sync() {
const state = getState();
// One service type is no choice at all — rule 11, nothing in the bar
// that has nothing to do.
const many = (state?.serviceTypes || []).length > 1;
menu.button.hidden = !many;
const wanted = viewedType();
menu.button.classList.toggle('active', Boolean(wanted) && wanted !== (state?.serviceType?.id || ''));
},
};
}
+171 -10
View File
@@ -5,7 +5,45 @@
(hidden channels, channel icons, timestamps, text size, flow direction)
persists in instance settings, and the data comes from the module's own
server, which keeps one connection to ProdCom for every dashboard: a
snapshot on connect (`state`), then each changed entry (`entry`). */
snapshot on connect (`state`), then each changed entry (`entry`).
Messaging: a click anywhere in the tile makes ProdCom the platform the
Send Message module's tiles send through (the channel the text goes out
*as* is picked in their Send as menu, among the channels ProdCom lets text
be typed onto); while it is, the tile's bottom edge carries the accent
stripe.
That is shared through the page bus below — see "Messaging on the page"
in docs/MODULE-GUIDE.md. Text typed from a dashboard comes back on the
stream as an entry with source "typed" and is drawn with a keyboard mark. */
/* ── the page bus (shared with the Send Message module) ───────────── */
const PROVIDER_ID = 'prodcom-transcript';
const BUS_EVENT = 'proddash:messaging:';
function messagingBus() {
if (!window.proddashMessaging) {
window.proddashMessaging = {
version: 1,
target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' },
unlock: { token: '', expiresAt: 0, reason: '' },
quickEmojis: [],
transcripts: new Map(),
senders: new Set(),
};
}
return window.proddashMessaging;
}
function emit(name, detail = {}) {
window.dispatchEvent(new CustomEvent(BUS_EVENT + name, { detail }));
}
function on(name, fn) {
const handler = (e) => fn(e.detail || {});
window.addEventListener(BUS_EVENT + name, handler);
return () => window.removeEventListener(BUS_EVENT + name, handler);
}
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
@@ -21,6 +59,8 @@ export default function create({ root, moduleApi }) {
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle
let stopped = false;
let lastStatus = null; // the link's last status, re-reported when the target changes
const bus = messagingBus();
/* ── DOM skeleton ───────────────────────────────────────────────── */
@@ -39,6 +79,26 @@ export default function create({ root, moduleApi }) {
const emptyEl = root.querySelector('.pt-empty');
const jumpBtn = root.querySelector('.pt-jump');
/* ── the send target: a click anywhere in the body points the senders here ── */
wrap.addEventListener('click', () => {
if (!channels.size) return; // nothing to send to yet
emit('select', { provider: PROVIDER_ID, destination: '' });
});
function isTarget() {
return bus.target.provider === PROVIDER_ID;
}
function paintTarget() {
if (stopped) return;
const on = isTarget();
if (on === wrap.classList.contains('is-target')) return;
wrap.classList.toggle('is-target', on);
if (lastStatus) applyStatus(lastStatus);
}
const offTarget = on('target', paintTarget);
/* ── title-bar controls ─────────────────────────────────────────── */
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
@@ -81,8 +141,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
@@ -95,7 +155,7 @@ export default function create({ root, moduleApi }) {
},
});
moduleApi.header.addButton({
const latestBtn = moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Latest',
onClick() {
@@ -112,6 +172,82 @@ export default function create({ root, moduleApi }) {
});
clearBtn.classList.add('pt-clear');
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
Seven controls need ~175 px, and the shell clips whatever the bar
cannot fit — on a 3-column tile among many, or one squeezed by the
fullscreen fit, that used to take the channel filter with it. Below
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
the same actions, so the filter button is the last thing to go. */
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
let moreMenuEl = null;
const moreMenu = moduleApi.header.addMenu({
icon: MORE_SVG,
title: 'More',
build(menu) {
menu.classList.add('pt-menu');
moreMenuEl = menu;
rebuildMoreMenu();
},
});
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
function addMoreItem(label, { on = null, onClick } = {}) {
const item = document.createElement('button');
item.className = 'pt-channel-item' + (on ? ' on' : '');
if (on !== null) {
item.setAttribute('role', 'menuitemcheckbox');
item.setAttribute('aria-checked', String(on));
} else {
item.setAttribute('role', 'menuitem');
}
const check = document.createElement('span');
check.className = 'check';
check.textContent = '✓';
const name = document.createElement('span');
name.className = 'name';
name.textContent = label;
item.append(check, name);
item.addEventListener('click', (e) => {
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
onClick();
});
moreMenuEl.appendChild(item);
}
function rebuildMoreMenu() {
if (!moreMenuEl) return;
moreMenuEl.innerHTML = '';
const s = moduleApi.instanceSettings;
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
const divider = document.createElement('div');
divider.className = 'pt-menu-divider';
moreMenuEl.appendChild(divider);
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
}
let compact = null; // unknown until the first measurement
function applyCompact(width) {
const next = width > 0 && width < COMPACT_PX;
if (next === compact) return;
compact = next;
for (const btn of secondaryBtns) btn.hidden = compact;
moreWrap.hidden = !compact;
if (!compact) moreMenu.close();
}
// the tile's own body tells the width; onResize only knows grid cells
const sizeWatch = new ResizeObserver((entries) => {
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
});
sizeWatch.observe(root);
applyCompact(root.clientWidth);
function applyInstanceSettings() {
const s = moduleApi.instanceSettings;
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
@@ -126,6 +262,7 @@ export default function create({ root, moduleApi }) {
flowBtn.classList.toggle('active', up);
flowBtn.title = up ? 'Newest first' : 'Oldest first';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
rebuildMoreMenu(); // its checkmarks mirror the buttons
}
applyInstanceSettings();
@@ -309,10 +446,18 @@ export default function create({ root, moduleApi }) {
const avatar = document.createElement('span');
avatar.className = 'avatar';
avatar.style.color = info.color;
avatar.innerHTML =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
'<circle cx="12" cy="8.5" r="3.2"/>' +
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
// speech is a person; typed text (from a dashboard, or ProdCom's own
// box) a keyboard; an automation a bolt
avatar.innerHTML = entry.source === 'typed'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="3" y="7" width="18" height="11" rx="2"/><path d="M7 11h1M11 11h1M15 11h1M8 14.5h8"/></svg>'
: entry.source === 'automation'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
'<circle cx="12" cy="8.5" r="3.2"/>' +
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
if (entry.source === 'typed' || entry.source === 'automation') el.classList.add(entry.source);
const ch = document.createElement('span');
ch.className = 'channel';
@@ -357,14 +502,27 @@ export default function create({ root, moduleApi }) {
function applyStatus(st) {
if (!st || typeof st !== 'object') return;
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
lastStatus = st;
if (st.state === 'ok') {
// the Send Message tiles point here: typed text goes out as the chosen channel
const as = !isTarget() ? ''
: bus.target.destinationLabel ? ` · sends go out as ${bus.target.destinationLabel}`
: ' · selected — no channel accepts typed text';
moduleApi.setStatus('ok', (st.message || 'Live') + as);
} else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
}
function applySnapshot(snap) {
if (!snap || typeof snap !== 'object') return;
channels.clear();
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
// On the page and able to send: that is what lets the Send Message tiles
// pick ProdCom by default when it is the only transcript showing.
if (channels.size && !bus.transcripts.has(moduleApi.instanceId)) {
bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: '' });
emit('transcripts', {});
}
wrap.classList.toggle('is-target', isTarget());
groups.clear();
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
rebuildChannelMenu();
@@ -415,6 +573,9 @@ export default function create({ root, moduleApi }) {
},
stop() {
stopped = true;
sizeWatch.disconnect();
offTarget();
if (bus.transcripts.delete(moduleApi.instanceId)) emit('transcripts', {});
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
+7 -4
View File
@@ -1,15 +1,18 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.3.2",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript",
"version": "1.4.0",
"proddash": ">=1.4.0",
"description": "Live ProdCom transcript; typed text to a ProdCom channel through the Send Message module",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching the ProdCom API on this network"
}
],
"provides": [
"messaging"
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
@@ -29,7 +32,7 @@
"host": "",
"port": 24480
},
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus’s comms. The Send Message module sends typed text to these channels through this connection."
},
"apiKey": {
"type": "password",
+217 -11
View File
@@ -11,19 +11,36 @@
* connections; ProdCom now sees one.)
*
* Routes (under /api/modules/prodcom-transcript):
* GET /state → { status, channels, groups, entries } (entries by date)
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
* changed entry, `clear`, and `status` when the link changes
* GET /state → { status, channels, groups, entries } (entries by date)
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
* changed entry, `clear`, and `status` when the link changes
* GET /messaging → what the Send Message module may send as: every
* channel that accepts typed text (typingEnabled), as
* an identity — typed text goes out *as* that channel
* (the module guide's contract)
* POST /messaging/send → { destination, text } with the Send Message unlock
* as a Bearer token — checked with that module over
* loopback, then POST /api/v1/channels/{id}/send, so a
* dashboard's typed text goes out on a ProdCom channel
* over this one connection. This module has no PIN and
* no send tile of its own.
*
* ProdCom's API, as the reference listener speaks it:
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
* { type, entry | data } wrapper, in a few historical shapes (see
* extractEntries). A bearer API key is sent when one is configured.
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
* text on a channel (it comes back on the stream with source "typed"). A
* bearer API key is sent when one is configured.
*/
const http = require('http');
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
const SENDER_ID = 'send-message';
const MAX_TEXT_CHARS = 2000;
const BODY_LIMIT_BYTES = 64 * 1024;
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
const ENTRIES_MAX = 300; // entries kept in memory
const RETRY_MIN_MS = 2000;
@@ -67,13 +84,92 @@ function normalizeEntry(e) {
text: String(e.text ?? ''),
date: e.date ? String(e.date) : new Date().toISOString(),
inProgress: Boolean(e.inProgress),
// audio (speech), typed (a dashboard or ProdCom's own box), automation
source: e.source === 'typed' || e.source === 'automation' ? String(e.source) : 'audio',
};
}
/* ── 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 readJson(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()
.then(() => fn(req, res))
.catch((err) => {
if (!res.headersSent) sendJson(res, 500, { error: err?.message || String(err) });
else try { res.end(); } catch { /* gone */ }
});
};
}
function bearer(req) {
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/.exec(String(req.headers.authorization || ''));
return m ? m[1] : '';
}
/**
* Is the unlock token on this request good? Only the Send Message module
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
* expiresAt } or { ok: false, status, error, code } ready to send back.
*/
async function verifyUnlock(req, shellPort) {
const token = bearer(req);
if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' };
if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' };
let res;
try {
res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' };
}
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send from a dashboard', code: 'no_sender' };
let body = {};
try { body = await res.json(); } catch { /* a non-JSON refusal */ }
if (res.status === 401 || body?.ok === false) return { ok: false, status: 401, error: body?.error || 'Locked — enter the PIN', code: body?.code || 'locked' };
if (!res.ok) return { ok: false, status: 502, error: `Send Message module answered HTTP ${res.status}`, code: 'sender_error' };
return { ok: true, expiresAt: Number(body?.expiresAt) || 0 };
}
let current = null; // the running instance, for routes()
module.exports = {
init({ config, log }) {
init({ config, log, shell }) {
const shellPort = Number(shell && shell.port) || 0;
const target = resolveTarget(config);
const apiKey = String(config.apiKey || '');
const channels = [];
@@ -148,7 +244,14 @@ module.exports = {
getJson('/api/v1/channels'),
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
]);
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' })));
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({
id: String(c.id),
name: String(c.name || c.id),
color: c.color || '#8b98a5',
// whether ProdCom lets text be typed onto this channel (an older
// ProdCom that doesn't say is taken at its word when asked)
typingEnabled: c.typingEnabled !== false,
})));
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
}
@@ -264,9 +367,75 @@ module.exports = {
log('no ProdCom server configured — set the endpoint in /admin');
}
/** A channel by id, or by name (case-insensitive) — ProdCom accepts both, the tiles use ids. */
function channelByRef(ref) {
const s = String(ref ?? '').trim();
if (!s) return null;
return channels.find((c) => c.id === s) || channels.find((c) => c.name.toLowerCase() === s.toLowerCase()) || null;
}
/**
* What the Send Message module may send as: the channels that accept
* typed text. A ProdCom channel is an identity, not a recipient — text
* typed onto it goes out *as* that channel — so the destinations are
* declared as such, and a channel with typing off is left out.
*/
function messagingDescriptor() {
const h = current ? current.health() : { status: 'connecting', message: 'Restarting…' };
return {
label: 'ProdCom',
identity: '',
destinationRole: 'identity',
status: { state: h.status, message: h.message },
destinations: channels.filter((c) => c.typingEnabled).map((c) => ({ id: c.id, label: c.name, color: c.color || '' })),
emptyMessage: channels.length
? 'No ProdCom channel accepts typed text — turn on typing for one in ProdCom'
: 'No ProdCom channels yet',
maxChars: MAX_TEXT_CHARS,
};
}
/** Typed text onto a channel: the one ProdCom connection does the talking. */
async function sendText(ch, text) {
if (!target) return { status: 502, error: 'No ProdCom server configured — set the endpoint in Admin → ProdCom', code: 'no_target' };
let res;
try {
res = await fetch(new URL(`/api/v1/channels/${encodeURIComponent(ch.id)}/send`, target), {
method: 'POST',
headers: { 'content-type': 'application/json; charset=utf-8', accept: 'application/json', ...authHeaders() },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
return { status: 502, error: `ProdCom unreachable (${err.cause?.code || err.code || err.message}) — not sent`, code: 'network' };
}
if (res.ok) {
res.body?.cancel?.().catch?.(() => {});
return { status: 200 };
}
let detail = '';
let code = '';
try {
const body = await res.json();
detail = String(body?.error?.message || '');
code = String(body?.error?.code || '');
} catch { /* no JSON */ }
const tail = detail ? ` — ${detail}` : '';
if (res.status === 401) return { status: 502, error: `ProdCom rejected the API key (${code || 'UNAUTHORIZED'}) — check Admin → ProdCom`, code: 'unauthorized' };
if (res.status === 404) return { status: 404, error: `ProdCom has no channel "${ch.name}" any more (${code || 'NOT_FOUND'}) — pick another in Send as`, code: 'not_found' };
if (res.status === 429) return { status: 429, error: `ProdCom is rate-limiting — try again in a moment${tail}`, code: 'ratelimited' };
if (res.status === 400 || res.status === 422) return { status: 400, error: `ProdCom refused the message (${code || res.status})${tail}`, code: 'refused' };
return { status: 502, error: `ProdCom answered HTTP ${res.status}${tail}`, code: code || `http_${res.status}` };
}
current = {
snapshot,
streams,
shellPort,
channelByRef,
messagingDescriptor,
sendText,
log,
health() {
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
// Nothing to connect to is a setting that is missing, not a link that
@@ -295,15 +464,52 @@ module.exports = {
},
routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
const unmounted = (res) => sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
return {
'GET /state': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
if (!current) return unmounted(res);
sendJson(res, 200, current.snapshot());
},
// The Send Message module reads this over loopback: where it may send.
'GET /messaging': (req, res) => {
if (!current) return unmounted(res);
sendJson(res, 200, current.messagingDescriptor());
},
// …and hands a message here, with the browser's unlock token. The token
// is checked with the Send Message module first: no PIN entered there,
// nothing goes out on a channel.
'POST /messaging/send': guard(async (req, res) => {
if (!current) return unmounted(res);
const v = await verifyUnlock(req, current.shellPort);
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
const ch = current.channelByRef(body.destination);
if (!ch) {
const s = String(body.destination ?? '').trim();
return sendJson(res, 400, { error: s ? `No ProdCom channel "${s}" — pick another in Send as` : 'Which ProdCom channel?', code: 'bad_destination' });
}
if (!ch.typingEnabled) {
return sendJson(res, 400, { error: `${ch.name} doesn't accept typed text — turn on typing for it in ProdCom`, code: 'typing_off' });
}
const out = await current.sendText(ch, text);
if (out.status !== 200) {
current.log(`send to ${ch.name} failed: ${out.error}`);
return sendJson(res, out.status, { error: out.error, code: out.code });
}
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
current.log(`sent to ${ch.name}: "${preview}${text.length > 60 ? '…' : ''}"`);
sendJson(res, 200, { ok: true, label: ch.name, expiresAt: v.expiresAt });
}),
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
+36 -1
View File
@@ -9,6 +9,28 @@
flex-direction: column;
}
/* ── the send target ──
A click anywhere in the tile makes ProdCom the platform the Send Message
tiles send to. While it is, a 3px accent stripe runs along the bottom edge
of the tile body — drawn just inside our root, since the tile chrome
around it belongs to the shell. The others hint at it faintly on hover.
(The same mark as the Slack transcript's.) */
[data-module="prodcom-transcript"] .pt-wrap::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--accent);
opacity: 0;
pointer-events: none;
z-index: 3;
transition: opacity 0.15s ease;
}
[data-module="prodcom-transcript"] .pt-wrap:hover::after { opacity: 0.3; }
[data-module="prodcom-transcript"] .pt-wrap.is-target::after { opacity: 1; }
/* ── channels button ──
Icon-only; the count appears next to it while some channels are hidden. */
@@ -20,9 +42,13 @@
}
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
[data-module="prodcom-transcript"] .tile-btn[hidden],
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
/* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */
it hangs off the body under a [data-module] host, so scoping still applies. */
[data-module="prodcom-transcript"] .pt-channel-item {
display: flex;
@@ -145,6 +171,15 @@
overflow-wrap: break-word;
}
/* typed text (from a dashboard, or ProdCom's own box) and automations carry
their own mark in the picture slot; the text reads the same */
[data-module="prodcom-transcript"] .pt-entry.typed .avatar,
[data-module="prodcom-transcript"] .pt-entry.automation .avatar {
background: color-mix(in srgb, var(--ch, var(--muted)) 10%, transparent);
outline: 1px dashed color-mix(in srgb, var(--ch, var(--muted)) 55%, transparent);
outline-offset: -1px;
}
[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); }
[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after {
content: "▍";
Binary file not shown.
Binary file not shown.
+34 -5
View File
@@ -70,6 +70,7 @@ const DEFAULTS = {
showDetail: true,
showSeconds: true,
leadingZeros: false,
showLoops: false,
clock24h: true,
clockSeconds: true,
clockDate: false,
@@ -123,10 +124,13 @@ export function framesToTc(n, nominal, df) {
* elapsed ms otherwise, or null when the snapshot can't say. The values as
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
* forward while running; `startedAt` is the fallback when they are absent.
* A timer measured at its own instant carries `asOf` (a phase-locked
* ProPresenter value — see the server's phase.js) which beats the source's
* snapshot time.
*/
export function valueAt(timer, sourceUpdatedAt, now) {
const running = timer.state === 'running';
const at = Number(sourceUpdatedAt) || now;
const at = Number(timer.asOf) || Number(sourceUpdatedAt) || now;
const dt = running ? Math.max(0, now - at) : 0;
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
if (timer.kind === 'countdown') {
@@ -381,7 +385,12 @@ export default function create({ root, moduleApi }) {
for (const source of state.sources) {
for (const timer of source.timers) {
if (keyOf(source, timer) !== solo) continue;
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
// A solo video tile with looping media hidden must not sit empty:
// it shows a quiet card instead of a countdown nobody should read.
const shown = timer.looping && pref('showLoops') !== true
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
: timer;
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
}
}
// The timer this tile was added for is not offered right now. Nothing is
@@ -395,7 +404,11 @@ export default function create({ root, moduleApi }) {
const items = [];
for (const timer of source.timers) {
const key = keyOf(source, timer);
if (!hidden.has(key)) items.push({ key, source, timer });
if (hidden.has(key)) continue;
// A looping background's time means nothing — the card sits out
// while a loop plays (per tile: the "Looping videos" switch).
if (timer.looping && pref('showLoops') !== true) continue;
items.push({ key, source, timer });
}
// Every timer of a live source hidden on purpose: nothing to say.
if (!items.length && source.timers.length) continue;
@@ -647,9 +660,25 @@ export default function create({ root, moduleApi }) {
statusTone = timer.status?.tone || 'muted';
}
} else {
const value = valueAt(timer, source.updatedAt, now);
let value = valueAt(timer, source.updatedAt, now);
// A running card must never tick backwards: an anchor correction or
// clock-offset wobble can re-place a second boundary by a few
// hundred ms, and a digit that regresses reads as broken. A small
// step against the direction of travel holds the shown value (time
// re-passes it within the jitter bound); a big one is a real
// reset/seek and snaps.
if (timer.state === 'running' && value !== null) {
const dir = timer.kind === 'countdown' ? -1 : 1;
const prev = card.mono;
if (prev && (value - prev) * dir < 0 && Math.abs(value - prev) < 1500) value = prev;
card.mono = value;
} else {
card.mono = null;
}
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
tone = toneFor(timer, value);
// A shown loop never warns or overruns — it wraps at zero by design,
// and a background flashing red every pass would cry wolf all service.
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
statusText = timer.status?.text || defaultStatus(timer, value, tone);
statusTone = timer.status?.tone || tone;
detailText = timer.detail || '';
+64 -33
View File
@@ -3,20 +3,26 @@
/**
* Built-in LTC listener — capture child + in-process decode.
*
* Spawns the module's native capture tool (ltc-capture, compiled from
* ltc-capture.swift beside it) for the admin-configured audio device, reads
* Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
* shipped prebuilt; source in native/) for the admin-configured audio device, reads
* the interleaved s16le stream, extracts the configured channel, and decodes
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
* onUpdate roughly 4×/s:
*
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<what is wrong and how to fix it>' } not capturing
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
*
* The capture binary is per-machine (gitignored): if it is missing this
* tries one swiftc compile of the source next to it, and otherwise says to
* build it on any same-architecture Mac and copy it over. A dead child is
* restarted with backoff — fast for a crash, slow for config-shaped
* failures (unknown device, ambiguous name) that never fix themselves.
* Error strings are two-tier on purpose: `error` is what a dashboard tile
* shows (short — "Not configured — see Admin"), `detail` is the full story
* for the admin health line and the log.
*
* The capture tool ships prebuilt inside the module, one binary per
* platform under bin/ (module guide, "Native helpers"): nothing is ever
* built on the machine running ProdDash. captureTool() picks the binary for
* this platform; when there is none, timecode reports itself off and the
* rest of the module carries on. A dead capture child is restarted with
* backoff — fast for a crash, slow for config-shaped failures (unknown
* device, ambiguity) that never fix themselves.
*
* macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
@@ -27,7 +33,7 @@
const path = require('path');
const fs = require('fs');
const { spawn, spawnSync } = require('child_process');
const { spawn } = require('child_process');
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
const RESTART_MS = 5000;
@@ -41,13 +47,43 @@ const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
/** Tile-facing words (short); the detail goes to admin health and the log. */
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
/** What the module ships in bin/, by platform and architecture. */
const HELPERS = {
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
win32: { x64: 'ltc-capture-win-x64.exe' },
};
/**
* The capture tool for this machine. It is never built here and never
* looked for anywhere else: the module ships it, or timecode is off on this
* platform and says so. `ready` means a usable binary at `bin`; otherwise
* `error` is the short tile text and `detail` names the platform.
*
* @returns {{ready:boolean, bin:string, error:string, detail:string}}
*/
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
return {
ready: false,
bin: '',
error: TOOL_MISSING,
detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`,
};
}
/**
* @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void}}
*/
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const bin = path.join(moduleDir, 'ltc-capture');
let child = null;
let stopped = false;
let restartTimer = null;
@@ -75,25 +111,13 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
return 'nosignal';
}
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
log('ltc-capture binary missing — trying a one-time swiftc build');
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
if (built.status === 0 && fs.existsSync(bin)) {
log('ltc-capture compiled');
return '';
}
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
+ 'and copy it here';
}
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
report({ error: missing });
const tool = captureTool(moduleDir);
if (!tool.ready) {
// No binary for this platform: say so and look again later — a module
// update remounts us anyway, this is only belt and braces.
report({ error: tool.error, detail: tool.detail });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -103,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
let decoder = null;
let stderrTail = '';
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
@@ -113,7 +137,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const channels = Number(m[2]);
const rate = Number(m[3]);
if (channel > channels) {
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
report({
error: 'Channel not on this device — see Admin',
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
});
try { child.kill(); } catch { /* exiting anyway */ }
return;
}
@@ -135,17 +162,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
});
child.on('error', (err) => {
report({ error: `LTC capture failed to start: ${err.message}` });
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
});
child.on('exit', (code) => {
child = null;
if (stopped) return;
// The capture's own failure text (unknown device, ambiguous name,
// permission hint) is the most useful thing we can show.
// permission hint) is the most useful DETAIL we have; the tile just
// needs to know the input is down and where to fix it.
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
const quickDeath = Date.now() - startedAt < 3000;
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
report({
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
});
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
});
}
@@ -188,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
};
}
module.exports = { createLtcListener };
module.exports = { createLtcListener, captureTool };
+7 -1
View File
@@ -1,7 +1,7 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.4.1",
"version": "1.7.0",
"proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules",
"provides": [
@@ -161,6 +161,12 @@
"default": false,
"group": "Show"
},
"showLoops": {
"type": "switch",
"label": "Looping videos",
"default": false,
"group": "Show"
},
"showHeadings": {
"type": "switch",
"label": "Source headings",
+48
View File
@@ -0,0 +1,48 @@
#!/bin/zsh
# Build this module's native helper for every macOS architecture it ships —
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
# from ltc-capture.swift beside this script. Run it on any Mac with the
# Xcode command line tools; both architectures cross-compile from either.
# The binaries are committed: a machine running ProdDash never builds
# anything (docs/MODULE-GUIDE.md, "Native helpers").
#
# Signed with a Developer ID Application certificate when one is installed
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
# entitlement, which a helper that reads a microphone needs under the
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
#
# Windows is not built here: there is no Windows implementation of this
# tool yet, so on Windows the module reports timecode off.
set -e -u
cd "$(dirname "$0")"
NAME="ltc-capture"
DEPLOYMENT_TARGET="13.0"
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
exit 1
}
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo "signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
SIGN=(--force --sign - --options runtime)
fi
mkdir -p ../bin
for ARCH in arm64 x86_64; do
OUT="../bin/$NAME-macos-$ARCH"
echo " compiling $OUT"
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
codesign --verify --strict "$OUT"
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
done
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -4,12 +4,12 @@
// The module decodes LTC in-process (ltc.js) and spawns this to move
// samples from an input device to a pipe. Production machines rarely have
// ffmpeg/sox, and shipping a third-party binary for that one job is
// overkill — this is the whole job, natively. The module compiles it on
// first use when swiftc is available; otherwise build it once on any
// same-architecture Mac and copy the binary next to this source:
// overkill — this is the whole job, natively. It ships prebuilt in the
// module's bin/ (one binary per platform, committed — a booth never
// builds anything). To rebuild after a change here, on any Mac with the
// Xcode command line tools:
//
// swiftc -O -o modules/propresenter-timers/ltc-capture \
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
// modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
//
// ltc-capture --list
// every input-capable device: name, input channels, sample rate
+113
View File
@@ -0,0 +1,113 @@
'use strict';
/**
* Phase lock for whole-second timer reports (the ProPresenter poller).
*
* ProPresenter reports a running timer as whole seconds — trunc(value):
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
* old poller re-anchored a tile's local count at the bottom of that interval
* on every poll, so the displayed digit ran up to a second ahead and then
* got yanked back a step whenever a fresh poll happened to sample the next
* flip later than the last one did — the poll clock and ProPresenter's
* second boundaries drift through each other, so tiles flickered
* "…12:33 → 12:34 → 12:33…" about once a second.
*
* This tracker turns the quantized reports into ONE stable anchor
* { valueMs, at } — the continuous value at a fixed instant — which the
* client extrapolates from. Rules:
*
* - The only real information in a poll is a FLIP: the report stepping one
* second in the direction of travel. At the flip instant the true value
* equals the interval boundary just crossed, and the flip happened between
* the last two polls, so their midpoint dates it to within half a poll.
* - While a poll's report matches what the anchor predicts (within TOL_MS
* around the reported second's interval), the anchor HOLDS — the same
* numbers are republished, so consumers count along one steady clock
* instead of a freshly re-phased one per sample.
* - A report the anchor can't explain re-anchors: at the flip boundary when
* this poll saw a clean one-second flip, else mid-interval (first sight,
* an operator reset/seek, a resume, or clock drift past TOL_MS).
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
* whole-second bias one way.
*
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
* crosses +1 s.
*/
/** How far a report may sit outside the anchor's prediction before it means
something real. A flip is dated to the midpoint of two polls, so its own
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
runs long); the tolerance must sit ABOVE that or consecutive flips
re-anchor each other forever, and safely below a full second so a real
one-second seek still registers. */
const TOL_MS = 450;
/** The continuous-value interval implied by a whole-second report. */
function intervalFor(kind, wholeMs) {
if (kind === 'countdown') {
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
}
return { lo: wholeMs, hi: wholeMs + 1000 };
}
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
function boundaryFor(kind, wholeMs) {
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
return wholeMs;
}
/**
* One tracker per timer. Feed one observation per poll; get back what to
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
* stable across polls while the reports stay consistent with it. Not
* running (or no value): the report is returned verbatim, dated at the poll.
*/
function createTimerPhase() {
let anchor = null; // { valueMs, at }
let kind = '';
let lastWhole = null;
let lastAt = 0;
let wasRunning = false;
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
const dir = k === 'countdown' ? -1 : 1;
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
&& lastWhole !== null && wholeMs !== null
&& wholeMs - lastWhole === dir * 1000;
const prevAt = lastAt;
lastWhole = wholeMs;
lastAt = pollAt;
wasRunning = Boolean(running);
if (k !== kind) anchor = null;
kind = k;
if (!running || wholeMs === null) {
anchor = null;
return { valueMs: wholeMs, asOf: pollAt };
}
const { lo, hi } = intervalFor(k, wholeMs);
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
// tolerance check may fail to catch for a long time (its error only
// shows at a poll landing more than TOL_MS past a flip). The first
// clean flip is strictly better evidence (±½ poll), so it always wins.
if (anchor && !(anchor.provisional && flip)) {
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
}
anchor = flip
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
return { observe };
}
module.exports = { createTimerPhase, TOL_MS };
@@ -0,0 +1,205 @@
'use strict';
/**
* Self-test for phase.js — the whole-second phase lock — plus the client's
* never-tick-backwards guard, driven by a simulated ProPresenter:
*
* node modules/propresenter-timers/phase.selftest.js
*
* The simulation runs a true continuous timer, reports it the way
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
* jitter), feeds the reports through createTimerPhase(), then renders the
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
*
* - the displayed digits NEVER step against the direction of travel
* (the reported bug: countdown tiles flicking back to the previous
* second about once a second),
* - each displayed second dwells ~1 s,
* - the published anchor stays within tolerance of the true value,
* - operator resets/seeks snap within a poll or two,
* - pause → resume re-locks,
* - a ProPresenter whose clock drifts against the server's stays locked.
*/
const { createTimerPhase, TOL_MS } = require('./phase');
let checks = 0;
let failures = 0;
function ok(name, cond, detail) {
checks += 1;
if (!cond) {
failures += 1;
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
}
}
/** Deterministic pseudo-random (so a failure reproduces). */
function makeRand(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
/** ProPresenter's report: whole seconds, truncated toward zero. */
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
/**
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
* timers report a frozen value via `frozen`).
*/
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
const rand = makeRand(seed);
const phase = createTimerPhase();
const dir = kind === 'countdown' ? -1 : 1;
const polls = [];
for (let t = 0; t <= durationMs;) {
const running = runningAt(t);
const value = trueValueAt(t);
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
t += pollMs + Math.floor(rand() * jitterMs);
}
// Render like client.js: between polls, extrapolate from the latest
// anchor; clamp sub-1.5 s regressions while running.
const shown = []; // { t, sec, err }
let mono = null;
let p = 0;
for (let t = polls[0].t; t <= durationMs; t += 25) {
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
const { out, running } = polls[p];
if (!running || out.valueMs === null) {
mono = null;
continue;
}
let value = out.valueMs + dir * (t - out.asOf);
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
mono = value;
const truth = trueValueAt(t);
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
}
return { name, dir, shown };
}
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
from legitimate corrections (absorbing clock drift re-places a boundary
by up to TOL_MS once in a while). */
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
let regressions = 0;
let maxAbsErr = 0;
const changes = [];
for (let i = 1; i < shown.length; i += 1) {
const step = shown[i].sec - shown[i - 1].sec;
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
if (step !== 0) changes.push(shown[i].t);
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
}
// Whole dwells only (drop the partial first and trailing segments), after
// the lock settles — the first observed flip corrects the provisional
// mid-interval anchor once, stretching or squeezing a boundary in the
// timer's first moments, which no one can see on a wall. A countdown
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
// allow one ~2 s dwell; everything else must be ~1 s.
const settled = shown.length ? shown[0].t + 2500 : 0;
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
const near = (d, target) => Math.abs(d - target) <= 200;
const long = dwells.filter((d) => near(d, 2000)).length;
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
}
/* 1. Plain countdown, many sub-second phases and poll seeds. */
for (let i = 0; i < 12; i += 1) {
const phase0 = (i * 83) % 1000;
const run = simulate({
name: `countdown q=${phase0}ms seed=${i}`,
kind: 'countdown',
seed: 1000 + i,
durationMs: 30000,
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
});
assertSteady(run);
}
/* 2. Elapsed timer (counts up). */
for (let i = 0; i < 6; i += 1) {
const run = simulate({
name: `elapsed seed=${i}`,
kind: 'elapsed',
seed: 2000 + i,
durationMs: 25000,
trueValueAt: (t) => 400 + i * 137 + t,
});
assertSteady(run);
}
/* 3. Operator reset mid-run: value jumps back up to full length. */
{
const run = simulate({
name: 'reset',
kind: 'countdown',
seed: 7,
durationMs: 24000,
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
});
// After the reset the shown value must reach the new region within ~2 polls.
const after = run.shown.filter((s) => s.t >= 13400);
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
// Before and after the jump, digits stay monotonic within each phase.
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
assertSteady(pre, { maxErr: 700 });
}
/* 4. Pause, then resume: no stale-anchor explosion. */
{
const frozen = 8000;
const run = simulate({
name: 'pause/resume',
kind: 'countdown',
seed: 11,
durationMs: 26000,
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
runningAt: (t) => t < 6000 || t >= 14000,
});
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
assertSteady(resumed);
}
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
re-locks over time instead of accumulating a visible error. */
{
const run = simulate({
name: 'clock drift',
kind: 'countdown',
seed: 13,
durationMs: 120000,
trueValueAt: (t) => 130000 - t * 1.002,
});
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
// a backwards step (the separate regression check still demands zero).
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
}
/* 6. Idle timers pass through untouched. */
{
const phase = createTimerPhase();
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
}
if (failures) {
console.error(`\n${failures} of ${checks} checks FAILED`);
process.exit(1);
}
console.log(`phase.selftest: all ${checks} checks passed`);
+215 -22
View File
@@ -81,7 +81,8 @@
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener');
const { createLtcListener, captureTool } = require('./ltc-listener');
const { createTimerPhase } = require('./phase');
const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */
@@ -209,6 +210,7 @@ function sanitizeTimer(t) {
label: String(t.label || id).slice(0, 120),
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
state: STATES.has(t.state) ? t.state : 'stopped',
asOf: numOrNull(t.asOf),
startedAt: numOrNull(t.startedAt),
elapsedMs: numOrNull(t.elapsedMs),
remainingMs: numOrNull(t.remainingMs),
@@ -250,6 +252,23 @@ function createProPresenterSource({ config, log }) {
let updatedAt = 0;
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
let timerConfigs = new Map();
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
const phases = new Map();
/* The video countdown — time left on what the presentation layer is
playing, from the transport routes (verified live on ProPresenter 21.4):
GET /v1/transport/presentation/current → { is_playing, uuid, name,
artist, audio_only, duration } (no looping flag exists — inferred)
GET /v1/transport/presentation/time → bare seconds, sub-second
float — better than /v1/timer/video_countdown's whole-second string,
so no quantization phase lock is needed, only an anchor hold. */
let media = null; // { uuid, name, duration, playing } from the slow lane
let mediaAt = 0;
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
let videoPos = null; // playback position in seconds (float)
let videoAsOf = 0; // server ms when videoPos was measured
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
let loopingUuid = ''; // media seen wrapping — a looping background
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
@@ -325,9 +344,21 @@ function createProPresenterSource({ config, log }) {
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
state = atStart ? 'idle' : 'paused';
}
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
// local count at the bottom of that second every poll made the digits run
// ahead and flick back a step about once a second — phase.js turns the
// reports into one stable anchor { valueMs, asOf } the tiles count from.
let phase = phases.get(uuid);
if (!phase) {
phase = createTimerPhase();
phases.set(uuid, phase);
}
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
const v = anchored.valueMs;
const asOf = anchored.asOf;
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
return {
@@ -335,6 +366,7 @@ function createProPresenterSource({ config, log }) {
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
kind,
state,
asOf,
startedAt,
elapsedMs,
remainingMs,
@@ -367,16 +399,143 @@ function createProPresenterSource({ config, log }) {
// Timers reported live but missing from the (possibly stale) config
// list still deserve a card — type unknown until the slow lane catches up.
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
for (const uuid of phases.keys()) {
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
}
return out;
}
/**
* Follow the presentation transport layer. Failures here only drop the
* video card, never the whole source — the timers must survive a
* ProPresenter build with transport quirks.
*/
async function pollVideo() {
if (tick % SLOW_EVERY === 1) {
try {
const raw = await requestJson('/v1/transport/presentation/current');
const uuid = String(raw?.uuid || '');
if (!media || media.uuid !== uuid) {
if (media && media.uuid !== uuid) loopingUuid = '';
videoPos = null;
videoAnchor = null;
}
media = uuid ? {
uuid,
name: String(raw.name || ''),
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
playing: Boolean(raw.is_playing),
} : null;
mediaAt = Date.now();
transportOk = true;
} catch (err) {
media = null; // the slow lane retries in ~2 s
if (err && err.status === 404) transportOk = false; // no transport route on this build
}
}
if (!media) {
videoPos = null;
videoAnchor = null;
return;
}
try {
const pos = Number(await requestJson('/v1/transport/presentation/time'));
if (Number.isFinite(pos) && pos >= 0) {
// Same media, position jumped back by more than a stutter: it
// wrapped — a looping background. Sticks until the media changes.
// (A video manually re-triggered from the start looks the same and
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
videoPos = pos;
videoAsOf = Date.now();
} else {
videoPos = null;
}
} catch {
videoPos = null;
}
}
/** The video countdown as one synthetic timer card (id 'video'). The card
persists as an idle "—" between videos — a wall tile must not appear
and vanish with the media — and is only absent entirely on a
ProPresenter without transport routes. */
function videoTimer() {
if (transportOk !== true) return null;
if (!media) {
return {
id: 'video',
label: 'Video',
kind: 'countdown',
state: 'idle',
asOf: mediaAt || updatedAt,
startedAt: null,
elapsedMs: null,
remainingMs: null,
targetMs: null,
looping: false,
status: null,
detail: '',
};
}
const playing = media.playing;
const dur = media.duration > 0 ? media.duration * 1000 : null;
let remainingMs = null;
let asOf = videoAsOf || mediaAt;
if (videoPos !== null && dur !== null) {
remainingMs = Math.max(0, dur - videoPos * 1000);
if (playing) {
// Hold the published anchor while it still predicts the measured
// position: the tiles extrapolate locally, and a fresh anchor per
// poll would broadcast a frame every 500 ms for nothing.
const p = videoAnchor;
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
if (p && Math.abs(predicted - remainingMs) < 120) {
remainingMs = p.remainingMs;
asOf = p.asOf;
} else {
videoAnchor = { remainingMs, asOf };
}
} else {
videoAnchor = null;
}
} else {
videoAnchor = null;
}
const looping = media.uuid === loopingUuid;
return {
id: 'video',
label: 'Video',
kind: 'countdown',
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
asOf,
startedAt: null,
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
remainingMs,
targetMs: dur,
looping,
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
// The media name, without the file extension nobody reads on a wall.
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
};
}
function status() {
if (!configured) return 'error';
if (reachable) return 'ok';
return lastError ? 'error' : 'connecting';
}
function message() {
/** Short, for the tiles — a dashboard warns, the admin page explains.
The client prefixes the source label, so no "ProPresenter" here. */
function tileMessage() {
if (!configured) return 'Not configured — see Admin';
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return 'Unreachable';
}
/** The full story, for the admin health line. */
function adminMessage() {
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
@@ -384,7 +543,7 @@ function createProPresenterSource({ config, log }) {
function publish() {
if (stopped) return;
emit({ status: status(), message: message(), timers, updatedAt });
emit({ status: status(), message: tileMessage(), timers, updatedAt });
}
async function pollTick() {
@@ -400,6 +559,9 @@ function createProPresenterSource({ config, log }) {
const currentTimes = await requestJson('/v1/timers/current');
updatedAt = Date.now();
timers = mergeTimers(currentTimes, updatedAt);
await pollVideo();
const video = videoTimer();
if (video) timers.push(video);
reachable = true;
lastError = '';
} catch (err) {
@@ -430,7 +592,7 @@ function createProPresenterSource({ config, log }) {
clearTimeout(pollTimer);
},
health() {
return { status: status(), message: message() };
return { status: status(), message: adminMessage() };
},
};
}
@@ -444,7 +606,9 @@ function createLtcSource({ config, log, moduleDir }) {
let emit = () => {};
let listener = null;
let stopped = false;
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
/** The listener's latest word: { state, time, fps, df, ageMs } |
{ error, detail } (error = short tile text, detail = the admin/log
story) | null while starting. */
let word = null;
function rateText() {
@@ -496,8 +660,13 @@ function createLtcSource({ config, log, moduleDir }) {
kind: 'ltc',
start(e) {
emit = e;
// The capture tool ships prebuilt in the module; the listener picks
// it for this platform when it starts and says so when there is none.
if (!device) {
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
word = {
error: 'Not configured — see Admin',
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
};
publish();
return;
}
@@ -519,7 +688,8 @@ function createLtcSource({ config, log, moduleDir }) {
listener = null;
},
health() {
return { status: status(), message: message() };
// The admin page gets the full story; the tiles got the short one.
return { status: status(), message: (word && word.error && word.detail) || message() };
},
};
}
@@ -568,7 +738,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
let silenceTimer = null;
let backoff = BACKOFF_MIN_MS;
function message() {
/** Short, for the tiles; the lastError detail belongs to the admin line.
The client prefixes the source label, so it isn't repeated here. */
function tileMessage() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
if (state === 'connecting') return 'Connecting…';
return 'Unavailable';
}
function adminMessage() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
return `unreachable — ${lastError}`;
@@ -576,7 +754,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
function publish() {
if (stopped) return;
emit({ label, status: state, message: message(), timers, updatedAt });
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
}
function accept(raw) {
@@ -754,7 +932,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
if (!stopped && !stream && via === 'poll') connectStream();
},
health() {
return { status: state, message: message() };
return { status: state, message: adminMessage() };
},
};
}
@@ -922,6 +1100,8 @@ const GEAR = {
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
/** The video countdown: a timer card, plus whether looping media counts. */
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
/** The time of day: plain digits, so only how they read. */
@@ -935,6 +1115,7 @@ function gearFor(source, timer) {
if (source.kind === 'ltc') return GEAR.ltc;
if (source.kind === 'clock') return GEAR.clock;
if (timer.kind === 'clock') return GEAR.providerClock;
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
return GEAR.timer;
}
@@ -992,12 +1173,17 @@ module.exports = {
for (const t of v.timers) {
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
if (t.state === 'running' && v.updatedAt > 0) {
const dt = Math.max(0, now - v.updatedAt);
// A timer measured at its own instant (asOf — a phase-locked
// ProPresenter value) re-anchors from there; the rest from the
// source snapshot. Consumers see plain contract values as of `now`.
const at = Number(out.asOf) || v.updatedAt;
if (t.state === 'running' && at > 0) {
const dt = Math.max(0, now - at);
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
}
delete out.asOf;
timers.push(out);
}
}
@@ -1138,7 +1324,8 @@ module.exports = {
for (const t of src.timers) {
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
: src.kind === 'clock' ? 'The time of day on its own'
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
@@ -1147,7 +1334,7 @@ module.exports = {
return out;
},
routes() {
routes({ log }) {
return {
'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
@@ -1169,15 +1356,21 @@ module.exports = {
},
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
// options shape matches the admin select's optionsRoute contract. The
// capture tool ships prebuilt in the module (bin/); with none for this
// platform the list is empty and the error says so.
'GET /ltc/devices': (req, res) => {
const bin = path.join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
const tool = captureTool(__dirname);
if (!tool.ready) {
sendJson(res, 200, { options: [], error: tool.detail });
return;
}
const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
child.on('error', (e) => {
sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` });
});
child.on('exit', () => {
if (res.writableEnded) return;
+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;
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"id": "send-message",
"name": "Send Message",
"version": "1.0.0",
"proddash": ">=1.4.0",
"description": "PIN-protected sending to Slack, ProdCom and any other messaging module, from one tile",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 2
},
"defaultSize": {
"w": 3,
"h": 3
},
"configGroups": {
"PIN": {
"toggle": "pinEnabled",
"help": "Nothing leaves this machine until the PIN is entered on a Send message or Quick replies tile. One PIN unlocks every messaging tile on that dashboard page — Send message, Quick replies and Slack's reaction menu — until Auto-lock minutes pass without a send; a reload asks again. With the PIN switched off, the tiles send without asking. Sending stays refused while the PIN is on but not yet set."
},
"Presets": {
"help": "Quick replies are one per line, Label | Message. Add | platform:destination to bind one — slack:#production or prodcom:FOH — and that reply always goes there, whichever transcript is selected. Quick emojis are sent as a message of their own with one press from the Send message tile."
}
},
"configSchema": {
"pinEnabled": {
"type": "switch",
"label": "PIN",
"default": true,
"group": "PIN"
},
"pin": {
"type": "password",
"label": "PIN",
"default": "",
"group": "PIN",
"help": "4–8 digits; the pad unlocks by itself once they are all in."
},
"pinIdleMinutes": {
"type": "number",
"label": "Auto-lock (min)",
"default": 10,
"group": "PIN"
},
"quickReplies": {
"type": "text",
"label": "Quick replies",
"default": "",
"group": "Presets",
"help": "One per line: Label | Message. Add | slack:#channel or | prodcom:Channel to bind one to a destination."
},
"quickEmojis": {
"type": "string",
"label": "Quick emojis",
"default": "👍 🙏 ✅ 👀 🎉",
"group": "Presets",
"help": "Emoji separated by spaces, eight at most. Sent as plain text to whatever is selected."
}
},
"instanceSchema": {}
}
+806
View File
@@ -0,0 +1,806 @@
'use strict';
/**
* Send Message — server part.
*
* One place a dashboard sends from, whatever the platform. The module owns
* nothing upstream: it finds every module whose manifest says
* "provides": ["messaging"] (Slack, ProdCom, whatever comes next) through
* the shell's /api/modules over loopback, reads each one's destinations
* from its GET /messaging, and hands a message to the provider's
* POST /messaging/send — so Slack keeps its one Slack connection, ProdCom
* its one ProdCom connection, and this module never opens a second one.
*
* It also owns the PIN. A browser unlocks here and gets a token; every
* gated action anywhere — a send through this module, a reaction in a
* Slack transcript — carries that token, and the provider checks it with
* GET /verify over loopback before touching its upstream. No provider has
* a PIN of its own, and no browser on the network can reach a provider's
* send route without having entered the PIN here.
*
* GET /state providers (destinations, identity, status), pin flags, quick emojis
* GET /stream SSE `state` events (change-detected, heartbeated)
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
* with the PIN switched off: {} → a token, no questions
* POST /lock Bearer → forget that token
* GET /verify Bearer → { ok, expiresAt }, 401 otherwise — for providers
* GET /quick Bearer → preset labels, indexes and bindings (never the text)
* POST /send Bearer, { provider, destination, text }
* POST /quick Bearer, { index, provider, destination }
*
* The provider contract is in docs/MODULE-GUIDE.md ("Offering messaging").
*/
const crypto = require('crypto');
const SELF_ID = 'send-message';
const CAPABILITY = 'messaging';
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Re-read /api/modules: a newly installed provider shows up without a restart. */
const DISCOVER_MS = 30000;
const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot)
const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens
/** Each provider's /messaging, re-read this often while a tile is watching… */
const DESCRIBE_MS = 4000;
/** …and this slowly with nobody looking. */
const DESCRIBE_IDLE_MS = 30000;
/** A tile counts as watching for this long after its last /state or /stream. */
const IDLE_AFTER_MS = 30000;
const LOOPBACK_TIMEOUT_MS = 5000;
/** A provider's send may itself wait on its upstream. */
const SEND_TIMEOUT_MS = 15000;
const PIN_RE = /^\d{4,8}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
/** With the PIN off, a token is minted on request and simply lasts. */
const NO_PIN_TOKEN_MS = 7 * 24 * 60 * 60 * 1000;
const SEND_MIN_INTERVAL_MS = 1000;
const DEFAULT_MAX_CHARS = 4000;
const BODY_LIMIT_BYTES = 64 * 1024;
const UNLOCK_SWEEP_MS = 30000;
/** Set by init(), read by routes() — 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 readJson(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()
.then(() => fn(req, res))
.catch((err) => {
if (!res.headersSent) sendJson(res, 500, { error: err?.message || String(err) });
else try { res.end(); } catch { /* gone */ }
});
};
}
function bearer(req) {
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/.exec(String(req.headers.authorization || ''));
return m ? m[1] : '';
}
function clientIp(req) {
return String(req.socket?.remoteAddress || '');
}
/** Constant-time PIN compare (lengths are folded in after the timing-safe step). */
function pinMatches(given, expected) {
const a = Buffer.alloc(16);
const b = Buffer.alloc(16);
a.write(String(given).slice(0, 16));
b.write(String(expected).slice(0, 16));
const same = crypto.timingSafeEqual(a, b);
return same && given.length === expected.length;
}
function errorText(err) {
return String(err?.cause?.code || err?.code || err?.message || err || 'error');
}
/**
* "Label | Message | platform:destination" per line. A line with no "|" is
* both its label and its message. The binding (optional) names a provider
* — its module id, a prefix of it ("prodcom"), or its label ("Slack") — and
* one of its destinations by id or label ("#production", "FOH"); a reply
* that carries one always goes there, whatever is selected.
*/
function parseQuickReplies(text) {
const out = [];
for (const rawLine of String(text || '').split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const parts = line.split('|').map((p) => p.trim());
const label = parts[0];
const message = parts.length > 1 && parts[1] ? parts[1] : label;
const binding = parts.length > 2 ? parts[2] : '';
if (!label || !message) continue;
out.push({ label, message, binding });
}
return out;
}
/** "👍 🙏 ✅" → ['👍', '🙏', '✅'] — plain text tokens, eight at most. */
function parseQuickEmojis(text) {
return String(text || '').split(/\s+/).filter(Boolean).slice(0, 8);
}
/** A provider's GET /messaging answer, as the tiles and the send route use it. */
function normalizeDescriptor(id, fallbackLabel, body) {
const b = body && typeof body === 'object' ? body : {};
const st = b.status && typeof b.status === 'object' ? b.status : {};
const state = st.state === 'ok' || st.state === 'error' ? st.state : 'connecting';
const destinations = [];
const seen = new Set();
for (const d of Array.isArray(b.destinations) ? b.destinations : []) {
if (!d || typeof d !== 'object') continue;
const did = String(d.id ?? '').trim();
if (!did || seen.has(did)) continue;
seen.add(did);
destinations.push({ id: did, label: String(d.label || did), color: typeof d.color === 'string' ? d.color : '' });
}
const maxChars = Math.trunc(Number(b.maxChars));
return {
id,
label: String(b.label || fallbackLabel || id),
identity: String(b.identity || ''),
// recipient: a message goes *to* a destination (a Slack channel);
// identity: it goes out *as* one (a ProdCom channel) — the Send as menu
// lists only the latter, and the wording follows
destinationRole: b.destinationRole === 'identity' ? 'identity' : 'recipient',
status: { state, message: String(st.message || '') },
destinations,
// what to say when the list is empty, if the provider knows why
emptyMessage: String(b.emptyMessage || ''),
maxChars: maxChars > 0 ? Math.min(DEFAULT_MAX_CHARS * 10, maxChars) : DEFAULT_MAX_CHARS,
};
}
/** "Slack → #ops" / "ProdCom as FOH" — how a provider + destination pair reads. */
function pairLabel(descriptor, destination) {
return descriptor.destinationRole === 'identity'
? `${descriptor.label} as ${destination.label}`
: `${descriptor.label} → ${destination.label}`;
}
/* ── the module ─────────────────────────────────────────────────────── */
module.exports = {
init({ config, log, shell }) {
/* ── config ── */
const shellPort = Number(shell && shell.port) || 0;
const pinEnabled = config.pinEnabled !== false;
const pin = String(config.pin || '').trim();
const pinOk = PIN_RE.test(pin);
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
const quickReplies = parseQuickReplies(config.quickReplies);
const quickEmojis = parseQuickEmojis(config.quickEmojis);
/* ── state (all inside this closure so a re-init starts clean) ── */
let stopped = false;
const providers = new Map(); // module id → { id, label, descriptor | null, error, at }
let listError = ''; // the last /api/modules failure ('' while fine)
let listedAt = 0;
let discoverTimer = null;
let describeTimer = null;
let describing = null; // one descriptor sweep in flight
const streams = new Set();
const unlocks = new Map(); // token → { expiresAt, lastSendAt, ip }
const pinAttempts = new Map(); // ip → { count, lockedUntil }
let lastInterest = Date.now();
let lastSig = '';
const loopback = (path) => `http://127.0.0.1:${shellPort}${path}`;
function watched() {
return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS;
}
/* ── discovery: who offers messaging ── */
async function discover() {
discoverTimer = null;
if (stopped) return;
try {
if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)');
const res = await fetch(loopback('/api/modules'), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (stopped) return;
const mods = (Array.isArray(body?.modules) ? body.modules : [])
.filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes(CAPABILITY));
const keep = new Set();
for (const m of mods) {
keep.add(m.id);
const have = providers.get(m.id);
if (have) have.label = String(m.name || m.id);
else {
providers.set(m.id, { id: m.id, label: String(m.name || m.id), descriptor: null, error: '', at: 0 });
log(`module "${m.id}" offers messaging`);
}
}
for (const id of [...providers.keys()]) {
if (keep.has(id)) continue;
providers.delete(id);
log(`module "${id}" no longer offers messaging`);
}
listError = '';
listedAt = Date.now();
await describeAll();
} catch (err) {
listError = errorText(err);
broadcastIfChanged();
}
if (!stopped) {
discoverTimer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS);
discoverTimer.unref?.();
}
}
/** Read one provider's /messaging; a failure is kept on the entry, not thrown. */
async function describe(p) {
try {
const res = await fetch(loopback(`/api/modules/${encodeURIComponent(p.id)}/messaging`), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
});
if (res.status === 404) {
// Disabled or uninstalled, most likely: let the module list decide
// now rather than at the next 30 s pass, so the tiles drop it at once.
if (!discoverTimer && !stopped) {
discoverTimer = setTimeout(discover, 250);
discoverTimer.unref?.();
}
throw new Error('not answering (disabled or restarting)');
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (stopped) return;
p.descriptor = normalizeDescriptor(p.id, p.label, body);
if (p.error) log(`${p.label}: describing itself again`);
p.error = '';
p.at = Date.now();
} catch (err) {
const text = errorText(err);
if (p.error !== text) log(`${p.label}: cannot read its destinations — ${text}`);
p.error = text;
}
}
function describeAll() {
if (!describing) {
describing = Promise.all([...providers.values()].map(describe))
.then(() => broadcastIfChanged())
.finally(() => { describing = null; });
}
return describing;
}
function scheduleDescribe(delay = null) {
if (stopped) return;
clearTimeout(describeTimer);
describeTimer = setTimeout(async () => {
describeTimer = null;
await describeAll();
scheduleDescribe();
}, delay ?? (watched() ? DESCRIBE_MS : DESCRIBE_IDLE_MS));
describeTimer.unref?.();
}
/** Somebody is looking: catch up at once if the list is stale or empty. */
function wake() {
lastInterest = Date.now();
if (stopped) return;
if ((!providers.size || listError) && Date.now() - listedAt > DISCOVER_RETRY_MS && !discoverTimer) {
discoverTimer = setTimeout(discover, 50);
discoverTimer.unref?.();
} else if (describeTimer) {
// The idle cadence was in force; move to the watched one now.
scheduleDescribe(DESCRIBE_MS);
}
}
/* ── what the tiles see ── */
function providerView(p) {
const d = p.descriptor;
if (!d) {
return {
id: p.id,
label: p.label,
identity: '',
destinationRole: 'recipient',
status: { state: p.error ? 'error' : 'connecting', message: p.error ? `${p.label} module ${p.error}` : 'Reading destinations…' },
destinations: [],
emptyMessage: '',
maxChars: DEFAULT_MAX_CHARS,
error: p.error,
};
}
return { ...d, error: p.error, status: p.error ? { state: 'error', message: `${p.label} module ${p.error}` } : d.status };
}
function health() {
if (!shellPort) return { status: 'error', message: 'This module needs ProdDash 1.4.0 or newer' };
const pinLine = !pinEnabled ? 'PIN off' : pinOk ? 'PIN set' : 'PIN on but not set — sending refused';
if (listError) return { status: listedAt ? 'connecting' : 'error', message: `Cannot read the module list (${listError}) · ${pinLine}` };
if (!listedAt) return { status: 'connecting', message: 'Looking for messaging modules…' };
const list = [...providers.values()];
if (!list.length) return { status: 'error', message: `No module offers messaging — install Slack or ProdCom · ${pinLine}` };
const parts = list.map((p) => {
const v = providerView(p);
if (v.error) return `${v.label}: ${v.error}`;
const n = v.destinations.length;
const who = v.identity ? ` as ${v.identity}` : '';
// a provider that is up but unhappy says why, so the admin line does too
const why = v.status.state !== 'ok' && v.status.message ? ` — ${v.status.message}`
: !n && v.emptyMessage ? ` — ${v.emptyMessage}` : '';
const noun = v.destinationRole === 'identity' ? 'identit' + (n === 1 ? 'y' : 'ies') : 'destination' + (n === 1 ? '' : 's');
return `${v.label}${who} (${n} ${noun})${why}`;
});
const bad = list.some((p) => p.error || p.descriptor?.status.state === 'error');
const pending = list.some((p) => !p.descriptor);
return {
status: pinEnabled && !pinOk ? 'error' : bad ? 'error' : pending ? 'connecting' : 'ok',
message: `${parts.join(' · ')} · ${pinLine}`,
};
}
function snapshot() {
const h = health();
return {
providers: [...providers.values()].map(providerView),
pin: {
enabled: pinEnabled,
set: pinOk,
// the pad submits by itself once this many digits are in (never the PIN)
length: pinEnabled && pinOk ? pin.length : 0,
idleMinutes: idleMs / 60000,
},
quickEmojis,
quickCount: quickReplies.length,
status: { state: h.status, message: h.message },
};
}
function broadcastIfChanged() {
const snap = snapshot();
const sig = JSON.stringify(snap);
if (sig === lastSig) return;
lastSig = sig;
if (!streams.size) return;
const payload = `event: state\ndata: ${JSON.stringify(snap)}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
/* ── the PIN and its unlock tokens ── */
function sweepUnlocks() {
const now = Date.now();
for (const [t, u] of unlocks) if (u.expiresAt <= now) unlocks.delete(t);
for (const [ip, a] of pinAttempts) if (a.lockedUntil && a.lockedUntil <= now && !a.count) pinAttempts.delete(ip);
}
/** The unlock behind a token, or null; a hit slides its idle expiry along. */
function verify(token) {
const u = token ? unlocks.get(token) : null;
if (!u) return null;
const now = Date.now();
if (u.expiresAt <= now) {
unlocks.delete(token);
return null;
}
if (pinEnabled) u.expiresAt = now + idleMs;
return { token, ...u };
}
/** Everything a send needs before anyone is asked; answers the refusal itself. */
function sendGate(req, res) {
if (pinEnabled && !pinOk) {
sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
return null;
}
const u = verify(bearer(req));
if (!u) {
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
return null;
}
return u;
}
function unlock(req, res, given) {
const ip = clientIp(req);
const now = Date.now();
if (!pinEnabled) {
const t = crypto.randomBytes(32).toString('hex');
const expiresAt = now + NO_PIN_TOKEN_MS;
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
return sendJson(res, 200, { token: t, expiresAt, idleMs: NO_PIN_TOKEN_MS, pinEnabled: false });
}
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
let a = pinAttempts.get(ip);
if (a && a.lockedUntil > now) {
const secs = Math.ceil((a.lockedUntil - now) / 1000);
return sendJson(res, 429, { error: `Too many wrong PINs — locked for ${secs} s`, code: 'lockout', retryAfter: secs });
}
if (a && a.lockedUntil && a.lockedUntil <= now) a = null; // lockout served — clean slate
const str = String(given ?? '');
if (!PIN_RE.test(str) || !pinMatches(str, pin)) {
const count = (a ? a.count : 0) + 1;
if (count >= PIN_MAX_WRONG) {
pinAttempts.set(ip, { count: 0, lockedUntil: now + PIN_LOCKOUT_MS });
log(`wrong PIN ×${PIN_MAX_WRONG} from ${ip} — locked for ${PIN_LOCKOUT_MS / 1000} s`);
return sendJson(res, 429, { error: `Wrong PIN — locked for ${PIN_LOCKOUT_MS / 1000} s`, code: 'lockout', retryAfter: PIN_LOCKOUT_MS / 1000 });
}
pinAttempts.set(ip, { count, lockedUntil: 0 });
return sendJson(res, 401, { error: 'Wrong PIN', code: 'wrong_pin', triesLeft: PIN_MAX_WRONG - count });
}
pinAttempts.delete(ip);
const t = crypto.randomBytes(32).toString('hex');
const expiresAt = now + idleMs;
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
log(`unlocked from ${ip} (auto-lock after ${idleMs / 60000} min idle)`);
return sendJson(res, 200, { token: t, expiresAt, idleMs, pinEnabled: true });
}
/* ── destinations and bindings ── */
function findProvider(ref) {
const s = String(ref ?? '').trim().toLowerCase();
if (!s) return null;
for (const p of providers.values()) if (p.id.toLowerCase() === s) return p;
for (const p of providers.values()) {
const label = (p.descriptor?.label || p.label).toLowerCase();
if (label === s || p.id.toLowerCase().startsWith(s) || label.replace(/\s+/g, '') === s.replace(/\s+/g, '')) return p;
}
return null;
}
function findDestination(p, ref) {
const s = String(ref ?? '').trim();
if (!p?.descriptor || !s) return null;
const list = p.descriptor.destinations;
const exact = list.find((d) => d.id === s);
if (exact) return exact;
const name = s.replace(/^#/, '').toLowerCase();
return list.find((d) => d.label.replace(/^#/, '').toLowerCase() === name) || null;
}
/** A quick reply's "platform:destination" → { provider, destination } or { error }. */
function resolveBinding(binding) {
const s = String(binding || '').trim();
if (!s) return null;
const i = s.indexOf(':');
if (i > 0) {
const p = findProvider(s.slice(0, i));
if (!p) return { error: `No messaging module called "${s.slice(0, i).trim()}" for this quick reply` };
if (!p.descriptor) return { error: `${p.label} hasn't listed its destinations yet — try again in a moment` };
const d = findDestination(p, s.slice(i + 1));
if (!d) return { error: `${p.descriptor.label} has no destination "${s.slice(i + 1).trim()}" — check the quick reply in Admin → Send Message` };
return { provider: p, destination: d };
}
for (const p of providers.values()) {
const d = findDestination(p, s);
if (d) return { provider: p, destination: d };
}
return { error: `No destination "${s}" on any messaging module — check the quick reply in Admin → Send Message` };
}
/** The label a tile shows for a binding — resolved when it can be, the raw text otherwise. */
function bindingLabel(binding) {
const r = resolveBinding(binding);
if (!r) return '';
if (r.error) return String(binding).trim();
return pairLabel(r.provider.descriptor, r.destination);
}
/**
* The provider and destination a browser asked for. Both must be ones
* the module has listed — a PIN-unlocked browser chooses among what the
* providers offer, never an arbitrary id.
*/
async function chooseTarget(providerRef, destinationRef) {
const p = findProvider(providerRef);
if (!p) {
const s = String(providerRef ?? '').trim();
return { error: s ? `${s} isn't offering messaging right now — is the module enabled?` : 'Pick where to send first' };
}
if (!p.descriptor) await describe(p);
if (!p.descriptor) return { error: `${p.label} module ${p.error || 'is not answering'}` };
let d = findDestination(p, destinationRef);
if (!d && String(destinationRef ?? '').trim()) {
await describe(p); // a channel added a moment ago
d = findDestination(p, destinationRef);
}
if (!d) {
const s = String(destinationRef ?? '').trim();
return { error: s ? `${p.descriptor.label} has no destination "${s}" — pick another in Send as` : `Pick a ${p.descriptor.label} destination in Send as` };
}
return { provider: p, destination: d };
}
/* ── delivery: hand the message to the provider over loopback ── */
async function deliver(req, res, unlock, { provider, destination, text, what }) {
const now = Date.now();
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
}
const u = unlocks.get(unlock.token);
if (u) u.lastSendAt = now;
const max = provider.descriptor?.maxChars || DEFAULT_MAX_CHARS;
if (text.length > max) return sendJson(res, 400, { error: `Too long for ${provider.descriptor.label} — ${max} characters at most`, code: 'too_long' });
const label = provider.descriptor.label;
let answer;
try {
const r = await fetch(loopback(`/api/modules/${encodeURIComponent(provider.id)}/messaging/send`), {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
Authorization: `Bearer ${unlock.token}`,
},
body: JSON.stringify({ destination: destination.id, text }),
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
let body = {};
try { body = await r.json(); } catch { /* a non-JSON refusal */ }
answer = { status: r.status, body: body && typeof body === 'object' ? body : {} };
} catch (err) {
const message = `${label} module unreachable (${errorText(err)}) — not sent`;
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
return sendJson(res, 502, { error: message, code: 'provider_unreachable' });
}
if (answer.status === 404) {
return sendJson(res, 502, { error: `${label} module isn't answering (disabled or restarting) — not sent`, code: 'provider_gone' });
}
if (answer.status < 200 || answer.status >= 300 || answer.body.ok === false) {
const message = String(answer.body.error || `${label} answered HTTP ${answer.status}`);
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
const status = answer.status === 401 ? 401 : [400, 404, 429].includes(answer.status) ? answer.status : 502;
return sendJson(res, status, { error: message, code: answer.body.code || `http_${answer.status}` });
}
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
log(`${what} → ${pairLabel(provider.descriptor, destination)}: "${preview}${text.length > 60 ? '…' : ''}"`);
scheduleDescribe(500); // the provider may have something new to say about itself
return sendJson(res, 200, {
ok: true,
provider: provider.id,
providerLabel: label,
destination: destination.id,
label: String(answer.body.label || destination.label),
role: provider.descriptor.destinationRole,
expiresAt: u ? u.expiresAt : unlock.expiresAt,
});
}
/* ── go ── */
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
sweeper.unref?.();
if (!shellPort) log('the shell did not say which port it serves on — this module needs ProdDash 1.4.0 or newer');
else {
if (pinEnabled && !pinOk) log('the PIN is on but not set (4–8 digits) — send tiles stay locked until it is, or until the PIN is switched off');
if (!pinEnabled) log('PIN off — send tiles send without asking');
discoverTimer = setTimeout(discover, DISCOVER_FIRST_MS);
discoverTimer.unref?.();
scheduleDescribe();
}
current = {
streams,
snapshot,
health,
wake,
verify,
sendGate,
unlock,
chooseTarget,
deliver,
resolveBinding,
bindingLabel,
quickReplies,
lock(req, res) {
const t = bearer(req);
if (t) unlocks.delete(t);
return sendJson(res, 200, { ok: true });
},
};
return {
stop() {
stopped = true;
clearTimeout(discoverTimer);
clearTimeout(describeTimer);
clearInterval(heartbeat);
clearInterval(sweeper);
unlocks.clear(); // an admin Apply locks every send tile
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health,
};
},
/** The picker: the two send surfaces, one of each per dashboard. */
tiles() {
return [
{
id: 'send',
name: 'Send message',
description: 'A message to the transcript clicked last, on any platform',
single: true,
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
settings: { view: 'send' },
instanceSchema: {
showEmojis: { type: 'switch', label: 'Quick emojis', default: true },
},
},
{
id: 'quick',
name: 'Quick replies',
description: 'Preset buttons, one press each',
single: true,
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'quick' },
instanceSchema: {},
},
];
},
routes() {
const unmounted = (res) => sendJson(res, 503, { error: 'Send Message module is restarting — try again' });
return {
'GET /state': (req, res) => {
if (!current) return unmounted(res);
current.wake();
sendJson(res, 200, current.snapshot());
},
'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');
if (!current) {
res.write(`event: state\ndata: ${JSON.stringify({ providers: [], pin: { enabled: true, set: false, length: 0 }, quickEmojis: [], status: { state: 'connecting', message: 'Send Message module is restarting…' } })}\n\n`);
return void res.end();
}
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
current.wake();
req.on('close', () => current && current.streams.delete(res));
},
'POST /unlock': guard(async (req, res) => {
if (!current) return unmounted(res);
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
current.unlock(req, res, body.pin);
}),
'POST /lock': (req, res) => {
if (!current) return unmounted(res);
current.lock(req, res);
},
// Providers ask this over loopback before an upstream action: is this unlock good?
'GET /verify': (req, res) => {
if (!current) return unmounted(res);
const u = current.verify(bearer(req));
if (!u) return sendJson(res, 401, { ok: false, error: 'Locked — enter the PIN', code: 'locked' });
sendJson(res, 200, { ok: true, expiresAt: u.expiresAt });
},
// Labels, indexes and bindings only — the message text never leaves the server.
'GET /quick': (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
sendJson(res, 200, {
presets: current.quickReplies.map((q, index) => ({ index, label: q.label, target: current.bindingLabel(q.binding) })),
expiresAt: unlock.expiresAt,
});
},
'POST /send': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
const target = await current.chooseTarget(body.provider, body.destination);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
await current.deliver(req, res, unlock, { ...target, text, what: 'sent' });
}),
'POST /quick': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
const index = Math.trunc(Number(body.index));
const preset = Number.isInteger(index) ? current.quickReplies[index] : null;
if (!preset) return sendJson(res, 404, { error: 'No such quick reply — the list may have changed in Admin' });
// A bound reply ignores the selection; an unbound one needs it.
let target = current.resolveBinding(preset.binding);
if (!target) target = await current.chooseTarget(body.provider, body.destination);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
await current.deliver(req, res, unlock, { ...target, text: preset.message, what: `quick reply "${preset.label}"` });
}),
};
},
};
+527
View File
@@ -0,0 +1,527 @@
/* Send Message module — scoped to its tiles, shell variables only.
The gate (PIN pad), the compose box and the preset grid were the Slack
module's send surfaces until 1.3.0; they live here now, for every platform. */
[data-module="send-message"] .sm-wrap {
--sm-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
container-type: size;
font-size: var(--sm-size);
color: var(--text);
}
[data-module="send-message"] .sm-wrap:focus { outline: none; }
[data-module="send-message"] [hidden] { display: none !important; }
/* ── the gate ── */
[data-module="send-message"] .sm-gate,
[data-module="send-message"] .sm-compose {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px 10px;
overflow: auto;
}
[data-module="send-message"] .sm-note {
margin: auto;
padding: 10px;
text-align: center;
color: var(--muted);
font-size: 0.9em;
line-height: 1.45;
}
[data-module="send-message"] .sm-note.is-error { color: var(--danger); }
/* The pad never shrinks below its content (min-height stays `auto` on it
and on the key grid) — a shrunken grid would spill its rows under the
message line, which then swallows the taps. A tile too short for it
scrolls instead (.sm-gate has overflow: auto). */
[data-module="send-message"] .sm-pad {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 4px;
max-width: 340px;
width: 100%;
margin: 0 auto;
}
[data-module="send-message"] .sm-pad.sm-shake { animation: sm-shake 0.4s ease; }
@keyframes sm-shake {
10%, 90% { transform: translateX(-2px); }
20%, 80% { transform: translateX(3px); }
30%, 50%, 70% { transform: translateX(-5px); }
40%, 60% { transform: translateX(5px); }
}
/* one line: "Enter PIN · Slack · as Apollo Beach → #production" */
[data-module="send-message"] .sm-pad-head {
flex: none;
text-align: center;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="send-message"] .sm-pad-head b { font-weight: 700; color: var(--text); }
/* The dots ride on a real numeric input: tapping them focuses it, which is
what raises a phone's keyboard; on a desktop the keyboard types into it
directly. The input itself is invisible — the dots are its display. */
[data-module="send-message"] .sm-field {
flex: none;
position: relative;
height: 24px;
cursor: text;
}
[data-module="send-message"] .sm-dots {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
pointer-events: none;
}
[data-module="send-message"] .sm-dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--muted);
transition: background 0.12s, border-color 0.12s;
}
[data-module="send-message"] .sm-dot.on { background: var(--accent); border-color: var(--accent); }
[data-module="send-message"] .sm-pin-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: transparent;
caret-color: transparent;
font: inherit;
text-align: center;
outline: none;
-webkit-text-fill-color: transparent;
}
[data-module="send-message"] .sm-pin-input::selection { background: transparent; }
[data-module="send-message"] .sm-pin-input:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); }
[data-module="send-message"] .sm-keys {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, minmax(24px, 1fr));
gap: 4px;
}
[data-module="send-message"] .sm-key {
min-width: 0;
min-height: 24px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: clamp(13px, 9cqh, 26px);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="send-message"] .sm-key:hover { background: var(--border); }
[data-module="send-message"] .sm-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="send-message"] .sm-key svg { width: 1.1em; height: 1.1em; }
[data-module="send-message"] .sm-key-back { color: var(--muted); }
[data-module="send-message"] .sm-key-blank { visibility: hidden; }
[data-module="send-message"] .sm-pad.is-busy .sm-keys { opacity: 0.6; pointer-events: none; }
[data-module="send-message"] .sm-pad-msg {
flex: none;
min-height: 1.1em;
text-align: center;
font-size: 0.78em;
line-height: 1.1;
color: var(--muted);
}
[data-module="send-message"] .sm-pad-msg.is-error { color: var(--danger); font-weight: 600; }
/* A tile too short for the keypad (the smallest Send message / Quick replies
tiles) shows the dots and a hint instead — the dots still take a tap and
the keyboard, and a taller tile brings the keys back. .sm-wrap is the size
container the height is measured against. */
[data-module="send-message"] .sm-pad-hint {
display: none;
flex: none;
text-align: center;
font-size: 0.78em;
line-height: 1.3;
color: var(--muted);
}
@container (max-height: 176px) {
[data-module="send-message"] .sm-pad .sm-keys { display: none; }
[data-module="send-message"] .sm-pad .sm-pad-hint { display: block; }
[data-module="send-message"] .sm-pad { justify-content: center; }
}
/* ── unlocked ── */
[data-module="send-message"] .sm-compose { gap: 6px; }
[data-module="send-message"] .sm-compose-head {
flex: none;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 0.8em;
color: var(--muted);
}
[data-module="send-message"] .sm-sending {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="send-message"] .sm-lock-link {
flex: none;
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
[data-module="send-message"] .sm-lock-link:hover { color: var(--text); background: var(--border); }
/* The box takes whatever the quick emoji rows leave it, down to one line. */
[data-module="send-message"] .sm-box {
flex: 1 1 auto;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
}
[data-module="send-message"] .sm-input {
flex: 1 1 auto;
min-height: calc(1.4em + 16px);
width: 100%;
resize: none;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.4;
}
[data-module="send-message"] .sm-input:focus { outline: none; border-color: var(--accent); }
/* The grip: a small pill centred on the box's bottom edge, straddling the
border. Drag it up for more emoji rows. It is positioned over the box, so
it adds no height between the box and the keys. */
[data-module="send-message"] .sm-grip {
position: absolute;
left: 50%;
bottom: -6px;
transform: translateX(-50%);
width: 44px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: ns-resize;
touch-action: none;
z-index: 1;
-webkit-tap-highlight-color: transparent;
}
[data-module="send-message"] .sm-grip::before {
content: "";
width: 26px;
height: 4px;
border-radius: 2px;
background: var(--muted);
box-shadow: 0 0 0 1.5px var(--panel);
transition: background 0.12s, width 0.12s;
}
[data-module="send-message"] .sm-grip:hover::before,
[data-module="send-message"] .sm-grip:focus-visible::before,
[data-module="send-message"] .sm-grip.is-dragging::before { background: var(--accent); width: 34px; }
[data-module="send-message"] .sm-grip:focus { outline: none; }
/* The smallest Send message tile (two grid rows, a 110px body): the compose
area tightens its padding, gaps and the box's one-line minimum so that two
rows of quick emoji keys still fit under the box. .sm-wrap is the size
container the height is measured against. */
@container (max-height: 150px) {
[data-module="send-message"] .sm-compose { padding: 3px 10px; gap: 2px; }
[data-module="send-message"] .sm-input { padding: 4px 9px; line-height: 1.25; min-height: calc(1.25em + 10px); }
}
/* The Send button and the outcome icon sit right under the box, level with
the first row of keys; further rows of keys run on below them. */
[data-module="send-message"] .sm-compose-foot {
flex: none;
display: flex;
align-items: flex-start;
gap: 8px;
min-height: 1.6em;
min-width: 0;
}
/* the outcome of a send, as an icon beside the Send button: a check (accent
— green on the default theme) or a blocked sign (danger), fading out as
its five seconds run down; the sentence is the tooltip. The slot keeps its
width while empty so the Send button never shifts. */
[data-module="send-message"] .sm-feedback {
flex: none;
width: 1.45em;
min-height: 2.1em; /* the Send button's height, so the icon centres on it */
display: flex;
align-items: center;
justify-content: center;
}
[data-module="send-message"] .sm-countdown { align-self: center; }
[data-module="send-message"] .sm-feedback svg {
width: 1.45em;
height: 1.45em;
flex: none;
animation: sm-pop 0.18s ease-out, sm-fade 0.6s ease-in 4.4s forwards;
}
[data-module="send-message"] .sm-feedback.is-ok { color: var(--accent); }
[data-module="send-message"] .sm-feedback.is-error { color: var(--danger); cursor: help; }
/* the Quick replies foot has no Send button: the icon keeps to the right edge */
[data-module="send-message"] .sm-compose-foot.is-quick { justify-content: flex-end; }
@keyframes sm-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: none; opacity: 1; }
}
@keyframes sm-fade {
to { opacity: 0; }
}
[data-module="send-message"] .sm-countdown {
flex: none;
font-size: 0.75em;
color: var(--warn);
font-variant-numeric: tabular-nums;
}
[data-module="send-message"] .sm-sendbtn {
flex: none;
padding: 6px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
[data-module="send-message"] .sm-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
[data-module="send-message"] .sm-sendbtn:disabled { opacity: 0.6; cursor: default; }
/* quick emojis: one press each sends it as a message of its own. The keys
take whatever room the foot has to the left of the outcome icon and the
Send button. As one row (the default) they scroll sideways when the tile
is narrow (trackpad, touch, or the wheel — client.js turns a vertical
wheel into a horizontal scroll) while Send keeps its place; with more
rows allotted from the grip they wrap, --sm-quick-rows rows tall. One key
is --sm-key square, so a row's pitch is --sm-key plus the 4px gap. */
[data-module="send-message"] .sm-emoji-sendrow {
--sm-quick-rows: 1;
--sm-key: calc(2.31 * var(--sm-size));
flex: 1 1 auto;
display: flex;
gap: 4px;
min-width: var(--sm-key);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
[data-module="send-message"] .sm-emoji-sendrow.is-multi {
flex-wrap: wrap;
align-content: flex-start;
height: calc(var(--sm-quick-rows) * var(--sm-key) + (var(--sm-quick-rows) - 1) * 4px);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
[data-module="send-message"] .sm-emoji-sendrow::-webkit-scrollbar { display: none; }
/* more keys off one edge or the other: that edge fades out (client.js sets the classes) */
[data-module="send-message"] .sm-emoji-sendrow.has-more { mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent); }
[data-module="send-message"] .sm-emoji-sendrow.has-less { mask-image: linear-gradient(to right, transparent, #000 16px); }
[data-module="send-message"] .sm-emoji-sendrow.has-less.has-more { mask-image: linear-gradient(to right, transparent, #000 16px, #000 calc(100% - 16px), transparent); }
[data-module="send-message"] .sm-emoji-sendrow:empty { display: none; }
[data-module="send-message"] .sm-emoji-send {
flex: none;
width: var(--sm-key);
height: var(--sm-key);
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: 1.1em;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s, border-color 0.1s;
}
[data-module="send-message"] .sm-emoji-send:hover { border-color: var(--accent); background: var(--border); }
[data-module="send-message"] .sm-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="send-message"] .sm-emoji-send.is-busy { opacity: 0.6; }
[data-module="send-message"] .sm-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
[data-module="send-message"] .sm-emoji-char { font-size: 1em; line-height: 1; }
/* a :code: or a word rather than one emoji: a wider pill of small text */
[data-module="send-message"] .sm-emoji-send.is-text {
width: auto;
max-width: 7em;
padding: 0 7px;
}
[data-module="send-message"] .sm-emoji-send.is-text .sm-emoji-char {
font-size: 0.72em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* the preset grid: big targets, labels wrap */
[data-module="send-message"] .sm-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
grid-auto-rows: minmax(44px, auto);
gap: 6px;
align-content: start;
overflow-y: auto;
}
[data-module="send-message"] .sm-grid .sm-note { grid-column: 1 / -1; }
[data-module="send-message"] .sm-quick {
min-width: 0;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.25;
text-align: center;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="send-message"] .sm-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
[data-module="send-message"] .sm-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
[data-module="send-message"] .sm-quick.is-busy { opacity: 0.7; }
[data-module="send-message"] .sm-quick.is-sent {
background: var(--accent);
border-color: transparent;
color: var(--accent-text);
}
[data-module="send-message"] .sm-quick-label { overflow-wrap: anywhere; }
[data-module="send-message"] .sm-quick-chan {
font-size: 0.72em;
font-weight: 500;
color: var(--muted);
}
/* sent: the words step aside for a check, in the button itself */
[data-module="send-message"] .sm-quick { position: relative; }
[data-module="send-message"] .sm-quick-check {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
}
[data-module="send-message"] .sm-quick-check svg { width: 1.6em; height: 1.6em; animation: sm-pop 0.18s ease-out; }
[data-module="send-message"] .sm-quick.is-sent .sm-quick-label,
[data-module="send-message"] .sm-quick.is-sent .sm-quick-chan { visibility: hidden; }
[data-module="send-message"] .sm-quick.is-sent .sm-quick-check { display: flex; }
/* ── the Send as menu ──
The menu container is created and positioned by the shell (.tile-menu);
it hangs off the body under a [data-module] host, so scoping still applies. */
[data-module="send-message"] .sm-menu { min-width: 180px; max-height: min(60vh, 420px); overflow-y: auto; }
[data-module="send-message"] .sm-menu-head {
padding: 6px 9px 2px;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="send-message"] .sm-menu-head b { color: var(--text); font-weight: 700; }
[data-module="send-message"] .sm-menu-head.is-error b { color: var(--danger); }
[data-module="send-message"] .sm-menu-head.is-muted b { color: var(--muted); }
[data-module="send-message"] .sm-menu-head + .sm-menu-head,
[data-module="send-message"] .sm-menu-item + .sm-menu-head,
[data-module="send-message"] .sm-menu-note + .sm-menu-head {
margin-top: 4px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
[data-module="send-message"] .sm-menu-note {
padding: 4px 9px 6px;
font-size: 11px;
color: var(--muted);
max-width: 260px;
line-height: 1.35;
}
[data-module="send-message"] .sm-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 9px;
background: none;
border: none;
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-family: inherit;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
[data-module="send-message"] .sm-menu-item:hover { background: var(--border); }
[data-module="send-message"] .sm-menu-item .check {
width: 14px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="send-message"] .sm-menu-item.on .check { visibility: visible; }
[data-module="send-message"] .sm-menu-item.remembered .check { visibility: visible; color: var(--muted); opacity: 0.6; }
[data-module="send-message"] .sm-menu-item .name { font-weight: 600; }
[data-module="send-message"] .sm-menu-item.on .name { color: var(--accent); }
+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;
}
+114 -782
View File
File diff suppressed because it is too large Load Diff
+9 -43
View File
@@ -1,9 +1,12 @@
{
"id": "slack",
"name": "Slack",
"version": "1.2.1",
"version": "1.3.0",
"proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"description": "Live transcripts of this location's Slack channels; messages and reactions leave as the location through the Send Message module",
"provides": [
"messaging"
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
@@ -18,14 +21,10 @@
"configGroups": {
"Slack account": {
"collapsed": "whenSet",
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages sent from this machine through the Send Message module are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
},
"Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
},
"Sending": {
"toggle": "sendEnabled",
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name, and a destination the Send Message module's tiles can post to: a click on a transcript points them at its channel (its bottom edge lights up in the accent colour) — that is the only way a Slack channel is chosen; the Send as menu is for platforms that send as an identity. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
}
},
"configSchema": {
@@ -41,7 +40,7 @@
"label": "Send as",
"default": "",
"group": "Slack account",
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope. Applies to messages the Send Message module posts through this account."
},
"channels": {
"type": "multiselect",
@@ -49,7 +48,7 @@
"default": [],
"group": "Channels",
"optionsRoute": "/channels",
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
"help": "One transcript tile per channel, and one destination each for the Send Message module. Only channels the account has joined are listed — join one in Slack first."
},
"pollSeconds": {
"type": "number",
@@ -69,39 +68,6 @@
"label": "Thread replies",
"default": false,
"group": "Transcript"
},
"sendEnabled": {
"type": "switch",
"label": "Sending",
"default": false,
"group": "Sending"
},
"pin": {
"type": "password",
"label": "PIN",
"default": "",
"group": "Sending",
"help": "4–8 digits; the pad unlocks by itself once they are all in."
},
"pinIdleMinutes": {
"type": "number",
"label": "Auto-lock (min)",
"default": 10,
"group": "Sending"
},
"quickReplies": {
"type": "text",
"label": "Quick replies",
"default": "",
"group": "Sending",
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
},
"quickEmojis": {
"type": "string",
"label": "Quick emojis",
"default": "👍 🙏 ✅ 👀 🎉",
"group": "Sending",
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
}
},
"instanceSchema": {
+108 -339
View File
@@ -6,26 +6,29 @@
* One Slack Web API connection per ProdDash machine — the campus's shared
* location account (a user token), or a bot — polling the configured
* channels' recent history (one poller per channel, all inside one rate
* budget) and fanning the lot out to every tile over SSE, plus PIN-gated
* sending from this machine so a message reads as coming from the location.
* Browsers never talk to Slack; the token and the PIN stay here.
* budget) and fanning the lot out to every tile over SSE. Browsers never
* talk to Slack; the token stays here.
*
* GET /state snapshot: identity, channels (keyed by id), sending state
* GET /stream SSE `state` events (change-detected, heartbeated)
* GET /channels admin multiselect options — the channels the account is in
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
* POST /lock Bearer → forget that unlock token
* GET /quick Bearer → preset labels + indexes (never the message text)
* POST /send Bearer, { text, channel } → chat.postMessage
* POST /react Bearer, { channel, ts, name, remove? } → reactions.add / .remove
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
* Sending is not this module's: the Send Message module owns the PIN, the
* send tiles and the quick replies, and hands a message to this module's
* POST /messaging/send over loopback, so it still leaves through this one
* connection, as the location. Every outbound action — a message, an emoji
* reaction from a transcript — carries that module's unlock token and is
* checked with it (GET /verify, over loopback) before Slack is asked. This
* module has no PIN of its own and cannot post without one entered there.
*
* GET /state snapshot: identity, channels (keyed by id)
* GET /stream SSE `state` events (change-detected, heartbeated)
* GET /channels admin multiselect options — the channels the account is in
* GET /messaging the configured channels as destinations (the module guide's contract)
* POST /messaging/send Bearer (Send Message unlock), { destination, text } → chat.postMessage
* POST /react Bearer (Send Message unlock), { channel, ts, name, remove? } → reactions.add / .remove
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
* GET /emoji-image/<name>, GET /avatar/<u/USER|b/BOT> images proxied from Slack's CDN
* POST /quick Bearer, { index, channel } → post that preset
*
* `channel` on /send and /quick must be one of the configured channel ids
* (400 otherwise): a PIN-unlocked browser chooses among the admin's channels,
* never an arbitrary one. A quick reply with its own "| #channel" ignores the
* request's channel — an explicit binding wins over the selection.
* `destination` / `channel` must be one of the configured channel ids (400
* otherwise): an unlocked browser chooses among the admin's channels, never
* an arbitrary one.
*
* Rate budget. Slack allows an internal app roughly 50+ conversations.history
* calls a minute per token (Tier 3). The module keeps all its pollers under
@@ -46,8 +49,6 @@
* tools/slack-mock.js for development.
*/
const crypto = require('crypto');
const DEFAULT_API_BASE = 'https://slack.com/api';
const API_BASE = String(process.env.SLACK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
@@ -92,14 +93,11 @@ const FIRST_READ_STAGGER_MS = 250;
const MIN_SLOT_GAP_MS = 200;
/** tiles() waits at most this long for the first conversations.info answers after boot. */
const NAMES_WAIT_MS = 1500;
const PIN_RE = /^\d{4,8}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
const SEND_MIN_INTERVAL_MS = 1000;
const MAX_TEXT_CHARS = 4000;
const BODY_LIMIT_BYTES = 64 * 1024;
const DEFAULT_ICON = ':satellite_antenna:';
const UNLOCK_SWEEP_MS = 30000;
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
const SENDER_ID = 'send-message';
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
let current = null;
@@ -238,7 +236,6 @@ const EMOJI_NAMES = require('./emoji.json').names;
const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i;
/** How long the workspace's custom emoji list is believed before emoji.list is asked again. */
const EMOJI_LIST_TTL_MS = 60 * 60 * 1000;
let emojiReverse = null; // character (variation selector stripped) → the name Slack knows it by
/** The character for a standard code, '' for a custom or unknown one. */
function emojiChar(code) {
@@ -250,45 +247,6 @@ function emojiChar(code) {
return tone ? base.replace(/\uFE0F/g, '') + tone : base;
}
/** The short name for a character ('' if Slack has none) — a reaction needs the name, not the glyph. */
function emojiName(char) {
if (!emojiReverse) {
emojiReverse = new Map();
for (const [name, ch] of Object.entries(EMOJI_NAMES)) {
if (name.startsWith('skin-tone-')) continue;
const key = ch.replace(/\uFE0F/g, '');
if (!emojiReverse.has(key)) emojiReverse.set(key, name); // keys are sorted: "+1" wins over "thumbsup"
}
}
const s = String(char || '').replace(/\uFE0F/g, '');
const tone = /[\u{1F3FB}-\u{1F3FF}]$/u.exec(s);
if (tone) {
const base = emojiReverse.get(s.slice(0, -tone[0].length));
const toneName = Object.keys(EMOJI_NAMES).find((n) => n.startsWith('skin-tone-') && EMOJI_NAMES[n] === tone[0]);
return base && toneName ? `${base}::${toneName}` : '';
}
return emojiReverse.get(s) || '';
}
/**
* The admin's "Quick emojis" — "👍 🙏 :white_check_mark: :party_parrot:" →
* [{ name, char }]. A standard emoji gets both; a custom code keeps its name
* and no character (the browser draws the image); a glyph Slack has no name
* for can be sent but not reacted with (name ''). Eight at most.
*/
function parseQuickEmojis(text) {
const out = [];
for (const tok of String(text || '').split(/\s+/).filter(Boolean)) {
const code = /^:([^:\s]+(?:::skin-tone-[2-6])?):$/i.exec(tok);
if (code) {
const name = code[1].toLowerCase();
if (EMOJI_CODE_RE.test(name)) out.push({ name, char: emojiChar(name) });
} else out.push({ name: emojiName(tok), char: tok });
if (out.length >= 8) break;
}
return out;
}
/* Avatars and custom emoji are images on Slack's CDN; browsers only talk to
ProdDash, so the module fetches them and keeps them a while. */
const IMAGE_CACHE_MAX = 400;
@@ -453,38 +411,30 @@ function bearer(req) {
return m ? m[1] : '';
}
function clientIp(req) {
return String(req.socket?.remoteAddress || '');
}
/** Constant-time PIN compare (lengths are folded in after the timing-safe step). */
function pinMatches(given, expected) {
const a = Buffer.alloc(16);
const b = Buffer.alloc(16);
a.write(String(given).slice(0, 16));
b.write(String(expected).slice(0, 16));
const same = crypto.timingSafeEqual(a, b);
return same && given.length === expected.length;
}
/**
* "Label | Message | #channel" per line. A line with no "|" is both its label
* and its message; the channel (optional) is a name the account can see —
* a reply that carries one always goes there, whatever transcript is selected.
* Is the unlock token on this request good? Only the Send Message module
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
* expiresAt } or { ok: false, status, error, code } ready to send back.
*/
function parseQuickReplies(text) {
const out = [];
for (const rawLine of String(text || '').split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const parts = line.split('|').map((p) => p.trim());
const label = parts[0];
const message = parts.length > 1 && parts[1] ? parts[1] : label;
const channel = parts.length > 2 ? parts[2].replace(/^#/, '') : '';
if (!label || !message) continue;
out.push({ label, message, channel });
async function verifyUnlock(req, shellPort) {
const token = bearer(req);
if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' };
if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' };
let res;
try {
res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' };
}
return out;
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send or react from a dashboard', code: 'no_sender' };
let body = {};
try { body = await res.json(); } catch { /* a non-JSON refusal */ }
if (res.status === 401 || body?.ok === false) return { ok: false, status: 401, error: body?.error || 'Locked — enter the PIN', code: body?.code || 'locked' };
if (!res.ok) return { ok: false, status: 502, error: `Send Message module answered HTTP ${res.status}`, code: 'sender_error' };
return { ok: true, expiresAt: Number(body?.expiresAt) || 0 };
}
const SYSTEM_SUBTYPES = new Set([
@@ -497,8 +447,9 @@ const SYSTEM_SUBTYPES = new Set([
/* ── the module ─────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
init({ config, log, shell }) {
/* ── config ── */
const shellPort = Number(shell && shell.port) || 0;
const token = String(config.token || '').trim();
const sendAs = String(config.sendAs || '').trim();
const channelIds = configuredChannels(config);
@@ -508,12 +459,6 @@ module.exports = {
const historyCount = Math.min(HISTORY_MAX, Math.max(HISTORY_MIN, Math.trunc(Number(config.historyCount)) || 40));
const includeReplies = Boolean(config.includeReplies);
const threadFetchesPerPoll = Math.max(1, Math.floor(THREAD_FETCHES_PER_POLL / Math.max(1, channelIds.length)));
const sendEnabled = Boolean(config.sendEnabled);
const pin = String(config.pin || '').trim();
const pinOk = PIN_RE.test(pin);
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
const quickReplies = parseQuickReplies(config.quickReplies);
const quickEmojis = parseQuickEmojis(config.quickEmojis);
/* ── state (all inside this closure so a re-init starts clean) ── */
let stopped = false;
@@ -531,8 +476,6 @@ module.exports = {
let lastInterest = Date.now();
const epoch = Date.now(); // the phase-locked slots count from here
const streams = new Set();
const unlocks = new Map(); // token -> { expiresAt, lastSendAt, ip }
const pinAttempts = new Map(); // ip -> { count, lockedUntil }
/**
* One channel's poller: its own window, thread cache, timer and error,
@@ -724,21 +667,6 @@ module.exports = {
}
}
/** "#name" or a channel id → the channel id, or '' when the account can't see it. */
async function resolveChannelRef(ref) {
const s = String(ref || '').trim();
if (!s) return '';
if (/^[CG][A-Z0-9]{6,}$/.test(s)) return s;
const name = s.replace(/^#/, '').toLowerCase();
let list = await loadChannelList();
let hit = list.find((c) => c.name.toLowerCase() === name);
if (!hit) {
list = await loadChannelList(true);
hit = list.find((c) => c.name.toLowerCase() === name);
}
return hit ? hit.id : '';
}
/* ── normalisation ── */
function nameFor(m) {
if (m.subtype === 'bot_message' && m.username) return String(m.username);
@@ -845,7 +773,7 @@ module.exports = {
return { state: 'ok', message: `${labelOf(ch)} · live` };
}
/** The admin health line: who, which channels, what's wrong, sending. */
/** The admin health line: who, which channels, what's wrong. */
function health() {
if (!token) return { status: 'error', message: 'No Slack token — paste the location account’s User OAuth Token in Admin → Slack' };
if (authError) return { status: 'error', message: authError };
@@ -854,9 +782,8 @@ module.exports = {
const list = [...pollers.values()];
if (!list.length) return { status: 'error', message: `${who} · pick channels in Admin → Slack → Channels` };
const names = list.map(labelOf).join(', ');
const sending = !sendEnabled ? 'sending off' : !pinOk ? 'sending on — set a 4–8 digit PIN' : 'sending on';
const problems = [...new Set(list.filter((ch) => ch.error).map((ch) => (ch.error.includes(labelOf(ch)) ? ch.error : `${labelOf(ch)}: ${ch.error}`)))];
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')} · ${sending}` };
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')}` };
if (list.some((ch) => !ch.lastPollAt)) return { status: 'connecting', message: `${who} · ${names} · first read…` };
const stale = list.filter(isStale);
if (stale.length) {
@@ -864,7 +791,7 @@ module.exports = {
return { status: 'connecting', message: `${who} · ${names} · ${ago}` };
}
const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : '';
return { status: 'ok', message: `${who} · ${names}${cadence} · ${sending}` };
return { status: 'ok', message: `${who} · ${names}${cadence}` };
}
function channelView(ch) {
@@ -899,10 +826,6 @@ module.exports = {
includeReplies,
historyCount,
pollSeconds: intervalMs / 1000,
sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 },
// the pad submits by itself once this many digits are in (never the PIN)
pinLength: pinOk ? pin.length : 0,
quickEmojis,
emojiVersion,
status: { state: h.status, message: h.message, lastPollAt },
apiBase: API_BASE,
@@ -1135,57 +1058,17 @@ module.exports = {
]);
}
/* ── unlock tokens ── */
function sweepUnlocks() {
const now = Date.now();
for (const [t, u] of unlocks) if (u.expiresAt <= now) unlocks.delete(t);
for (const [ip, a] of pinAttempts) if (a.lockedUntil && a.lockedUntil <= now && !a.count) pinAttempts.delete(ip);
}
/** The unlock behind a request, or null (the client re-asks for the PIN). */
function unlockFor(req) {
const t = bearer(req);
const u = t ? unlocks.get(t) : null;
if (!u) return null;
if (u.expiresAt <= Date.now()) {
unlocks.delete(t);
return null;
}
return { token: t, ...u };
}
/** Everything a send needs before Slack is asked; answers the response itself when refusing. */
function sendGate(req, res) {
if (!sendEnabled) {
sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
return null;
}
if (!pinOk) {
sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
return null;
}
const u = unlockFor(req);
if (!u) {
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
return null;
}
return u;
}
/**
* The channel a browser asked to post to. Only a configured channel id is
* accepted — the selection is a choice among the admin's channels, never
* a way to reach some other one. Nothing asked for → the first configured.
* The channel a browser (or the Send Message module) asked to post to.
* Only a configured channel id is accepted — the selection is a choice
* among the admin's channels, never a way to reach some other one.
*/
function chooseTarget(requested) {
const s = String(requested ?? '').trim();
if (s) {
if (pollers.has(s)) return { id: s };
const name = lookupChannel(s);
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
}
if (!channelIds.length) return { error: 'No channel configured — pick channels in Admin → Slack → Channels' };
return { id: channelIds[0] };
if (!s) return { error: 'Which channel?' };
if (pollers.has(s)) return { id: s };
const name = lookupChannel(s);
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
}
async function postMessage(targetChannelId, text) {
@@ -1197,17 +1080,30 @@ module.exports = {
return slackCall(token, 'chat.postMessage', params, { json: true });
}
/** What the Send Message module may send to: the configured channels, as destinations. */
function messagingDescriptor() {
const h = health();
return {
label: 'Slack',
identity: sendingAs(),
destinationRole: 'recipient', // a channel is where a message goes, not who sends it
status: { state: h.status, message: h.message },
destinations: channelIds.map((id) => ({ id, label: `#${lookupChannel(id) || id}` })),
maxChars: MAX_TEXT_CHARS,
};
}
/**
* Add or remove a reaction on behalf of an unlock — the same gate as a
* send, since it is the location's name on it. The message's channel must
* be one of the configured ones. `already_reacted` / `no_reaction` count
* as done: Slack already holds the state that was asked for.
* Add or remove a reaction on behalf of a verified unlock — the same
* gate as a send, since it is the location's name on it. The message's
* channel must be one of the configured ones. `already_reacted` /
* `no_reaction` count as done: Slack already holds the state asked for.
*/
async function react(req, res, unlock, { channel, ts, name, remove }) {
async function react(req, res, verified, { channel, ts, name, remove }) {
if (!identity || authError) {
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
}
const target = String(channel ?? '').trim() ? chooseTarget(channel) : { error: 'Which channel?' };
const target = chooseTarget(channel);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
const stamp = String(ts ?? '').trim();
if (!/^\d{1,16}\.\d{1,9}$/.test(stamp)) return sendJson(res, 400, { error: 'Which message?' });
@@ -1225,52 +1121,30 @@ module.exports = {
return sendJson(res, status, { error: message, code: err.code });
}
}
const u = unlocks.get(unlock.token);
if (u) u.expiresAt = Date.now() + idleMs;
log(`${remove ? 'removed' : 'added'} :${code}: on ${stamp} in #${targetName} as ${sendingAs()}`);
if (pollers.has(target.id)) pollSoon(pollers.get(target.id));
return sendJson(res, 200, { ok: true, expiresAt: u ? u.expiresAt : unlock.expiresAt, channel: `#${targetName}` });
return sendJson(res, 200, { ok: true, expiresAt: verified.expiresAt, channel: `#${targetName}` });
}
/**
* Post on behalf of an unlock; answers the response. `targetId` is an
* already-validated configured channel; `presetChannel` (a quick reply's
* own "| #channel") overrides it.
*/
async function deliver(req, res, unlock, { text, targetId, presetChannel = '', what }) {
const now = Date.now();
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
}
unlocks.get(unlock.token).lastSendAt = now;
/** Post to an already-validated configured channel; answers the response. */
async function deliver(req, res, verified, { text, targetId, what }) {
if (!identity || authError) {
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
}
let target = targetId || '';
if (presetChannel) {
try {
target = await resolveChannelRef(presetChannel);
} catch (err) {
return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: presetChannel }) });
}
if (!target) return sendJson(res, 400, { error: `No channel named #${String(presetChannel).replace(/^#/, '')} that ${identity.name} can see` });
}
if (!target) return sendJson(res, 400, { error: 'No channel configured — pick channels in Admin → Slack → Channels' });
const targetName = lookupChannel(target) || target;
const targetName = lookupChannel(targetId) || targetId;
try {
const data = await postMessage(target, text);
const u = unlocks.get(unlock.token);
if (u) u.expiresAt = Date.now() + idleMs;
const data = await postMessage(targetId, text);
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
log(`${what} → #${targetName} as ${sendingAs()}: "${preview}${text.length > 60 ? '…' : ''}"`);
if (pollers.has(target)) pollSoon(pollers.get(target));
if (pollers.has(targetId)) pollSoon(pollers.get(targetId));
return sendJson(res, 200, {
ok: true,
ts: String(data.ts || ''),
label: `#${targetName}`,
channel: `#${targetName}`,
channelId: target,
channelId: targetId,
sendingAs: sendingAs(),
expiresAt: u ? u.expiresAt : unlock.expiresAt,
expiresAt: verified.expiresAt,
});
} catch (err) {
const message = describeError(err, { name: sendingAs() || identity.name, channel: targetName });
@@ -1290,12 +1164,9 @@ module.exports = {
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
sweeper.unref?.();
if (!token) log('no Slack token configured — paste one in /admin');
else {
if (sendEnabled && !pinOk) log('sending is on but no valid PIN (4–8 digits) is set — send tiles stay locked');
if (!channelIds.length) log('no channels configured — pick some in /admin → Slack → Channels');
else {
if (!Array.isArray(config.channels) || !config.channels.length) {
@@ -1311,8 +1182,8 @@ module.exports = {
streams,
snapshot,
health,
quickReplies,
sendGate,
shellPort,
messagingDescriptor,
deliver,
react,
/** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */
@@ -1331,7 +1202,6 @@ module.exports = {
if (kind === 'b') return botIcons.get(id) || '';
return '';
},
unlockFor,
chooseTarget,
namesReady,
touch() { lastInterest = Date.now(); },
@@ -1347,44 +1217,8 @@ module.exports = {
loadChannelList,
channelIds,
channelName: (id) => lookupChannel(id),
pinOk,
sendEnabled,
identityName: () => identity?.name || '',
sendingAs,
unlock(req, res, given) {
if (!sendEnabled) return sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
const ip = clientIp(req);
const now = Date.now();
let a = pinAttempts.get(ip);
if (a && a.lockedUntil > now) {
const secs = Math.ceil((a.lockedUntil - now) / 1000);
return sendJson(res, 429, { error: `Too many wrong PINs — locked for ${secs} s`, code: 'lockout', retryAfter: secs });
}
if (a && a.lockedUntil && a.lockedUntil <= now) a = null; // lockout served — clean slate
const str = String(given ?? '');
if (!PIN_RE.test(str) || !pinMatches(str, pin)) {
const count = (a ? a.count : 0) + 1;
if (count >= PIN_MAX_WRONG) {
pinAttempts.set(ip, { count: 0, lockedUntil: now + PIN_LOCKOUT_MS });
log(`wrong PIN ×${PIN_MAX_WRONG} from ${ip} — locked for ${PIN_LOCKOUT_MS / 1000} s`);
return sendJson(res, 429, { error: `Wrong PIN — locked for ${PIN_LOCKOUT_MS / 1000} s`, code: 'lockout', retryAfter: PIN_LOCKOUT_MS / 1000 });
}
pinAttempts.set(ip, { count, lockedUntil: 0 });
return sendJson(res, 401, { error: 'Wrong PIN', code: 'wrong_pin', triesLeft: PIN_MAX_WRONG - count });
}
pinAttempts.delete(ip);
const t = crypto.randomBytes(32).toString('hex');
const expiresAt = now + idleMs;
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
log(`unlocked from ${ip} (auto-lock after ${idleMs / 60000} min idle)`);
return sendJson(res, 200, { token: t, expiresAt, idleMs, sendingAs: sendingAs() });
},
lock(req, res) {
const t = bearer(req);
if (t) unlocks.delete(t);
return sendJson(res, 200, { ok: true });
},
};
return {
@@ -1398,8 +1232,6 @@ module.exports = {
}
}
clearInterval(heartbeat);
clearInterval(sweeper);
unlocks.clear(); // an admin Apply locks every send tile
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
@@ -1412,16 +1244,17 @@ module.exports = {
/**
* The picker: one transcript per configured channel, titled with the
* channel's name (the shell makes the entry's name the tile's title), then
* the two send surfaces, one of each per dashboard. Between init() and a
* poller's first answer a name may still be unknown — "#<id>" then.
* channel's name (the shell makes the entry's name the tile's title).
* Between init() and a poller's first answer a name may still be unknown
* — "#<id>" then. The send surfaces that used to follow here are the Send
* Message module's since 1.3.0; a layout's old Send message / Quick
* replies tiles draw nothing until they are removed.
*/
async tiles({ config }) {
const c = current;
const ids = c ? c.channelIds : configuredChannels(config || {});
if (c) await c.namesReady();
const who = c?.sendingAs() ? ` as ${c.sendingAs()}` : '';
const entries = ids.map((id) => ({
return ids.map((id) => ({
id: `transcript:${id}`,
name: `#${(c && c.channelName(id)) || id}`,
description: 'Live transcript',
@@ -1429,27 +1262,6 @@ module.exports = {
minSize: { w: 2, h: 2 },
settings: { view: 'transcript', channel: id },
}));
entries.push(
{
id: 'send',
name: 'Send message',
description: `PIN-protected message to the transcript clicked last${who}`,
single: true,
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
settings: { view: 'send' },
},
{
id: 'quick',
name: 'Quick replies',
description: `PIN-protected preset buttons${who}`,
single: true,
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'quick' },
},
);
return entries;
},
routes() {
@@ -1498,22 +1310,6 @@ module.exports = {
}
}),
'POST /unlock': guard(async (req, res) => {
if (!current) return unmounted(res);
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
current.unlock(req, res, body.pin);
}),
'POST /lock': (req, res) => {
if (!current) return unmounted(res);
current.lock(req, res);
},
// The emoji a tile may need to draw — fetched once per page, again when the version bumps.
'GET /emoji': (req, res) => {
if (!current) return unmounted(res);
@@ -1530,35 +1326,19 @@ module.exports = {
await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, ''))));
}),
// A reaction as the location — PIN-gated like a send.
'POST /react': guard(async (req, res) => {
// The Send Message module reads this over loopback: where it may send.
'GET /messaging': (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
await current.react(req, res, unlock, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
}),
// Labels and indexes only — the message text never leaves the server.
'GET /quick': (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
sendJson(res, 200, {
presets: current.quickReplies.map((q, index) => ({ index, label: q.label, channel: q.channel ? `#${q.channel}` : '' })),
expiresAt: unlock.expiresAt,
});
sendJson(res, 200, current.messagingDescriptor());
},
'POST /send': guard(async (req, res) => {
// …and hands a message here, with the browser's unlock token. The token
// is checked with the Send Message module first: no PIN entered there,
// nothing is posted.
'POST /messaging/send': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
const v = await verifyUnlock(req, current.shellPort);
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
let body;
try {
body = await readJson(req);
@@ -1568,34 +1348,23 @@ module.exports = {
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
const target = current.chooseTarget(body.channel);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
await current.deliver(req, res, unlock, { text, targetId: target.id, what: 'sent' });
const target = current.chooseTarget(body.destination ?? body.channel);
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_destination' });
await current.deliver(req, res, v, { text, targetId: target.id, what: 'sent' });
}),
'POST /quick': guard(async (req, res) => {
// A reaction as the location — the same unlock as a send.
'POST /react': guard(async (req, res) => {
if (!current) return unmounted(res);
const unlock = current.sendGate(req, res);
if (!unlock) return;
const v = await verifyUnlock(req, current.shellPort);
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
let body;
try {
body = await readJson(req);
} catch (err) {
return sendJson(res, 400, { error: err.message });
}
const index = Math.trunc(Number(body.index));
const preset = Number.isInteger(index) ? current.quickReplies[index] : null;
if (!preset) return sendJson(res, 404, { error: 'No such quick reply — the list may have changed in Admin' });
// A channel the browser names is checked even when the preset's own
// binding is about to override it — an unconfigured id is never accepted.
const target = current.chooseTarget(body.channel);
if (target.error && (body.channel || !preset.channel)) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
await current.deliver(req, res, unlock, {
text: preset.message,
targetId: target.id || '',
presetChannel: preset.channel,
what: `quick reply "${preset.label}"`,
});
await current.react(req, res, v, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
}),
};
},
+18 -308
View File
@@ -1,7 +1,8 @@
/* Slack module — scoped to its tiles, shell variables only.
--sl-size (per tile, from the header's A− / A+) drives every font size.
Transcript rows follow the ProdCom transcript's layout: a square picture,
then a name line with the time, the text, the reactions. */
then a name line with the time, the text, the reactions. The send tiles
and the PIN pad moved to the Send Message module in 1.3.0. */
[data-module="slack"] .sl-wrap {
--sl-size: 14px;
@@ -34,8 +35,8 @@
/* ── the send target ── */
/* A click anywhere in a transcript's body makes its channel the one Send
message and Quick replies post to. The chosen one carries a 3px accent
/* A click anywhere in a transcript's body makes its channel the one the
Send Message module's tiles post to. The chosen one carries a 3px accent
stripe along the bottom edge of the tile body — drawn just inside our
root, since the tile chrome around it belongs to the shell. The others
hint at it faintly on hover. */
@@ -279,307 +280,6 @@
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
/* ── send / quick replies: the gate ── */
[data-module="slack"] .sl-gate,
[data-module="slack"] .sl-compose {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px 10px;
overflow: auto;
}
[data-module="slack"] .sl-note {
margin: auto;
padding: 10px;
text-align: center;
color: var(--muted);
font-size: 0.9em;
line-height: 1.45;
}
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
/* The pad never shrinks below its content (min-height stays `auto` on it
and on the key grid) — a shrunken grid would spill its rows under the
message line, which then swallows the taps. A tile too short for it
scrolls instead (.sl-gate has overflow: auto). */
[data-module="slack"] .sl-pad {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 4px;
max-width: 340px;
width: 100%;
margin: 0 auto;
}
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
@keyframes sl-shake {
10%, 90% { transform: translateX(-2px); }
20%, 80% { transform: translateX(3px); }
30%, 50%, 70% { transform: translateX(-5px); }
40%, 60% { transform: translateX(5px); }
}
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
[data-module="slack"] .sl-pad-head {
flex: none;
text-align: center;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
[data-module="slack"] .sl-dots {
flex: none;
display: flex;
justify-content: center;
gap: 8px;
height: 14px;
}
[data-module="slack"] .sl-dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--muted);
transition: background 0.12s, border-color 0.12s;
}
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
[data-module="slack"] .sl-keys {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, minmax(24px, 1fr));
gap: 4px;
}
[data-module="slack"] .sl-key {
min-width: 0;
min-height: 24px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: clamp(13px, 9cqh, 26px);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-key:hover { background: var(--border); }
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
[data-module="slack"] .sl-key-back { color: var(--muted); }
[data-module="slack"] .sl-key-blank { visibility: hidden; }
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
[data-module="slack"] .sl-pad-msg {
flex: none;
min-height: 1.1em;
text-align: center;
font-size: 0.78em;
line-height: 1.1;
color: var(--muted);
}
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
/* A tile too short for the keypad (the smallest Send message / Quick replies
tiles) shows the dots and a hint instead — digits typed on the keyboard
still land, and a taller tile brings the keys back. .sl-wrap is the size
container the height is measured against. */
[data-module="slack"] .sl-pad-hint {
display: none;
flex: none;
text-align: center;
font-size: 0.78em;
line-height: 1.3;
color: var(--muted);
}
@container (max-height: 176px) {
[data-module="slack"] .sl-pad .sl-keys { display: none; }
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
[data-module="slack"] .sl-pad { justify-content: center; }
}
/* ── send / quick replies: unlocked ── */
[data-module="slack"] .sl-compose { gap: 6px; }
[data-module="slack"] .sl-compose-head {
flex: none;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 0.8em;
color: var(--muted);
}
[data-module="slack"] .sl-sending {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-lock-link {
flex: none;
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: none;
color: var(--muted);
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
[data-module="slack"] .sl-input {
flex: 1;
min-height: 2.6em;
width: 100%;
resize: none;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.4;
}
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
[data-module="slack"] .sl-compose-foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 1.6em;
}
[data-module="slack"] .sl-msg {
flex: 1;
min-width: 0;
font-size: 0.8em;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
[data-module="slack"] .sl-countdown {
flex: none;
font-size: 0.75em;
color: var(--warn);
font-variant-numeric: tabular-nums;
}
[data-module="slack"] .sl-sendbtn {
flex: none;
padding: 6px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
/* quick emojis: one press each sends it as a message of its own */
[data-module="slack"] .sl-emoji-sendrow {
flex: none;
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
[data-module="slack"] .sl-emoji-send {
flex: none;
width: 2.1em;
height: 2.1em;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 5%, var(--panel));
color: var(--text);
font: inherit;
font-size: 1.1em;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s, border-color 0.1s;
}
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
/* the preset grid: big targets, labels wrap */
[data-module="slack"] .sl-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
grid-auto-rows: minmax(44px, auto);
gap: 6px;
align-content: start;
overflow-y: auto;
}
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
[data-module="slack"] .sl-quick {
min-width: 0;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.25;
text-align: center;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
[data-module="slack"] .sl-quick.is-sent {
background: var(--accent);
border-color: transparent;
color: var(--accent-text);
}
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
[data-module="slack"] .sl-quick-chan {
font-size: 0.72em;
font-weight: 500;
color: var(--muted);
}
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
/* ── the reaction menu (on document.body, so nothing clips it) ── */
[data-module="slack"].sl-emoji-menu {
@@ -671,7 +371,17 @@
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
/* locked: the PIN is entered in the Send Message module's tile, not here */
[data-module="slack"] .sl-emoji-locked {
padding: 4px 2px 2px;
font-size: 0.86em;
line-height: 1.4;
color: var(--muted);
}
[data-module="slack"] .sl-emoji-locked b { color: var(--text); font-weight: 700; }
[data-module="slack"] .sl-emoji-reason {
display: block;
margin-top: 4px;
color: var(--warn);
font-size: 0.9em;
}
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.4.2",
"version": "0.5.0",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+48
View File
@@ -0,0 +1,48 @@
#!/bin/zsh
# Build this module's native helper for every macOS architecture it ships —
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
# from spl-capture.swift beside this script. Run it on any Mac with the
# Xcode command line tools; both architectures cross-compile from either.
# The binaries are committed: a machine running ProdDash never builds
# anything (docs/MODULE-GUIDE.md, "Native helpers").
#
# Signed with a Developer ID Application certificate when one is installed
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
# entitlement, which a helper that reads a microphone needs under the
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
#
# Windows is not built here: there is no Windows implementation of this
# tool yet, so on Windows the module reports measurement off.
set -e -u
cd "$(dirname "$0")"
NAME="spl-capture"
DEPLOYMENT_TARGET="13.0"
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
exit 1
}
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo "signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
SIGN=(--force --sign - --options runtime)
fi
mkdir -p ../bin
for ARCH in arm64 x86_64; do
OUT="../bin/$NAME-macos-$ARCH"
echo " compiling $OUT"
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
codesign --verify --strict "$OUT"
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
done
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -8,10 +8,11 @@
// measurement is done here and only the answers cross the pipe.
//
// Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager, ~2 s to build:
// code, no package manager. It ships prebuilt in the module's bin/ (one
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
//
// swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
// modules/spl-meter/native/build.sh (both architectures)
//
// spl-capture --list
// every input-capable device: name, input channels, sample rate
+10 -20
View File
@@ -20,7 +20,7 @@
*/
const path = require('path');
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
const { createSplListener, captureTool } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
@@ -120,12 +120,9 @@ module.exports = {
pinger: null,
};
// Before anything else: the device picker shells out to this tool, so a
// machine that installed the module from the catalog (source only — the
// binary is gitignored) needs the build kicked off here. Waiting for a
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
// The capture tool ships prebuilt in the module (bin/); with none for
// this platform every reading is off and the health line says so.
state.toolMissing = captureTool(moduleDir).missing;
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
@@ -185,9 +182,7 @@ module.exports = {
return {
stop() { teardown(); },
health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
@@ -311,11 +306,10 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (missing) {
if (state) state.toolMissing = missing;
log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
const tool = captureTool(__dirname);
if (tool.missing) {
log(`device list unavailable — ${tool.missing}`);
json(res, 200, { options: [], error: tool.missing });
return;
}
try {
@@ -447,7 +441,7 @@ function teardown() {
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
@@ -456,10 +450,6 @@ function listDevices() {
});
}
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, {
+36 -85
View File
@@ -3,7 +3,8 @@
/**
* Sound level listener — owns the native capture child.
*
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
* source in native/) for the
* admin-configured device and channels, reads its JSON lines, and hands the
* latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more.
@@ -13,12 +14,11 @@
* { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring
*
* The capture binary is per-machine and gitignored, exactly like the Timers
* module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event
* loop, and from module scope so the device picker can trigger it too — see
* ensureCaptureBinary for what both of those cost the first time round.
* The capture tool ships prebuilt inside the module, one binary per
* platform under bin/ (module guide, "Native helpers"), exactly like the
* Timers module's ltc-capture: nothing is ever built on the machine running
* ProdDash. captureTool() picks the binary for this platform; when there is
* none, measurement reports itself off and the rest of the module carries on.
*
* The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -31,72 +31,31 @@ const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
const BUILDING = 'Building the capture tool — this takes a minute or two';
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
/** What the module ships in bin/, by platform and architecture. */
const HELPERS = {
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
win32: { x64: 'spl-capture-win-x64.exe' },
};
/**
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
* everything that needs it.
* The capture tool for this machine. It is never built here and never
* looked for anywhere else: the module ships it, or measurement is off on
* this platform and says so. `bin` is the usable binary; otherwise
* `missing` is what to tell the admin page and the tiles.
*
* This lives at module scope rather than inside a listener because of a trap
* the first version walked into: the admin page's device picker runs the tool
* with --list, but the build only ran when a listener started, and a listener
* only starts once a device has been picked. On a machine that got the module
* from the catalog — source only, the binary being gitignored — the picker
* was empty, and nothing would ever fill it.
*
* It also must never block. It was a spawnSync once, copied from the LTC
* listener where the source is a third the size and builds in seconds. Here
* it took nearly two minutes on the production Mac and held the Node event
* loop for all of it, freezing every other module, then did it again on the
* next retry.
*
* The binary should normally already be there: built beside the app, or
* copied from any Mac of the same architecture. This is the fallback.
*
* @returns {string} '' when the tool is ready, otherwise what to say.
* @returns {{bin:string, missing:string}}
*/
let building = false;
let buildFailed = false;
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'spl-capture');
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return BUILDING;
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})`
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return BUILDING;
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
return {
bin: '',
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
};
}
const RESTART_MS = 5000;
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
*/
function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null;
let stopped = false;
@@ -124,13 +82,6 @@ function createSplListener(opts) {
let meta = null;
let lastFrameWall = 0;
/** '' when the tool is ready, otherwise what to say about it. */
function ensureBinary() {
return ensureCaptureBinary({ moduleDir, log, onReady() {
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
} });
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -150,12 +101,12 @@ function createSplListener(opts) {
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
onUpdate({ error: missing });
// A build in flight calls start() itself when it lands; anything else
// is config-shaped and should back off hard rather than spin.
if (missing !== BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
const tool = captureTool(moduleDir);
if (tool.missing) {
// No binary for this platform: say so and look again later — a module
// update remounts us anyway, this is only belt and braces.
onUpdate({ error: tool.missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -171,7 +122,7 @@ function createSplListener(opts) {
let stderrTail = '';
let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
@@ -254,4 +205,4 @@ function createSplListener(opts) {
};
}
module.exports = { createSplListener, ensureCaptureBinary };
module.exports = { createSplListener, captureTool };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.9.0",
"version": "1.13.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+120
View File
@@ -443,6 +443,7 @@ a.btn { text-decoration: none; }
margin-bottom: 10px;
}
.catalog-main { flex: 1; min-width: 0; }
.catalog-pick { margin: 5px 10px 0 0; flex: none; }
.catalog-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 2px; }
@@ -481,6 +482,73 @@ details.admin-fold > summary.admin-fold-title::after {
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
/* Alerts: a row per level — a swatch running that level's cadence between
the two chosen colours (the keyframes are the shell's, in style.css), the
level's name, and its two pickers — then the test row. */
.alert-levels {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.alert-level {
display: flex;
align-items: center;
gap: 12px;
}
.alert-preview {
flex: none;
width: 56px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--alert-a, var(--border));
animation: alert-flash var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
}
.alert-preview.low { --alert-period: 3.2s; --alert-ease: ease-in-out; }
.alert-preview.medium { --alert-period: 1.1s; --alert-ease: ease-in-out; }
.alert-preview.critical { --alert-period: 0.6s; --alert-ease: steps(1, end); }
.alert-level-name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.alert-level-name strong { font-size: 13px; }
.alert-level-name span { font-size: 12px; color: var(--muted); }
.alert-level input[type="color"] {
flex: none;
width: 44px;
height: 26px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
}
.alert-level input[type="color"]:focus { outline: none; border-color: var(--accent); }
.alert-test {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.alert-test-label { font-size: 13px; color: var(--muted); }
.alert-test-select {
height: 26px;
padding: 0 6px;
font: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.alert-test-select:focus { outline: none; border-color: var(--accent); }
.theme-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
@@ -580,3 +648,55 @@ details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
gap: 8px;
margin-left: auto;
}
/* an orderable multiselect (manifest `"orderable": true`): not a ticklist but
a list built on purpose — the picks, numbered, each with a nudge either way
and a way out, over a dropdown holding whatever is left to add. Their
position is the setting, so it has to be visible and movable. */
.multiselect-row.is-picked {
gap: 6px;
cursor: default;
padding: 4px 7px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.multiselect-add {
width: 100%;
margin-top: 2px;
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
cursor: pointer;
}
.multiselect-add:focus { outline: none; border-color: var(--accent); }
.multiselect-add:disabled { color: var(--muted); cursor: default; }
.multiselect-rank {
flex: none;
min-width: 16px;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--accent);
}
.multiselect-row .switch-label { flex: 1; min-width: 0; }
.multiselect-move {
flex: none;
width: 20px;
height: 20px;
padding: 0;
line-height: 1;
font-size: 9px;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
}
.multiselect-move:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.multiselect-move:disabled { opacity: 0.3; cursor: default; }
+322 -14
View File
@@ -27,6 +27,10 @@ const shellVersionEl = document.getElementById('shell-version');
const themeSection = document.getElementById('theme-section');
const themeSwatches = document.getElementById('theme-swatches');
const themeStatus = document.getElementById('theme-status');
const alertsSection = document.getElementById('alerts-section');
const alertLevelsEl = document.getElementById('alert-levels');
const alertsStatus = document.getElementById('alerts-status');
const alertTestChannel = document.getElementById('alert-test-channel');
/** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = '';
@@ -64,6 +68,7 @@ function showLogin() {
layoutsSection.hidden = true;
updateCard.hidden = true;
themeSection.hidden = true;
alertsSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus();
@@ -328,15 +333,31 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
// Kept as an ORDER, not a set: with `orderable` the position is the
// value (a priority), and even without it the saved order is what the
// shell stores, so losing it here would quietly reshuffle a module's list.
let chosen = (Array.isArray(value) ? value : []).map(String).filter(Boolean);
const orderable = Boolean(spec.orderable);
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const fill = (options) => {
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
const syncChosen = () => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
const v = input.value;
const at = chosen.indexOf(v);
if (input.checked && at < 0) chosen.push(v); // newly on: lowest priority
else if (!input.checked && at >= 0) chosen.splice(at, 1);
}
};
const move = (v, by) => {
const at = chosen.indexOf(v);
const to = at + by;
if (at < 0 || to < 0 || to >= chosen.length) return;
chosen.splice(to, 0, chosen.splice(at, 1)[0]);
};
const fill = (options) => {
syncChosen();
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
const missing = chosen.filter((v) => !options.some((opt) => optValue(opt) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('div');
@@ -346,13 +367,13 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = chosen.has(input.value);
input.checked = chosen.includes(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
text.textContent = optLabel(opt);
row.append(toggle, text);
row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself
@@ -368,21 +389,114 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
// A list built on purpose, in an order that means something: the picks,
// each numbered and movable, and a dropdown holding everything not picked
// yet. Switches cannot carry an order — and an account with forty service
// types is forty switches to read past before finding the two that matter.
const fillPicked = (options) => {
list.innerHTML = '';
const byValue = new Map(options.map((opt) => [optValue(opt), opt]));
chosen.forEach((v, i) => {
const opt = byValue.get(v);
const row = document.createElement('div');
row.className = 'multiselect-row is-picked';
const rank = document.createElement('span');
rank.className = 'multiselect-rank';
rank.textContent = String(i + 1);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = opt ? optLabel(opt) : `${v} (saved — not listed now)`;
const btn = (glyph, title, on, off) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'multiselect-move';
b.textContent = glyph;
b.title = title;
b.disabled = Boolean(off);
b.addEventListener('click', (e) => { e.preventDefault(); on(); });
return b;
};
row.append(
rank,
text,
btn('▲', 'Higher priority', () => { move(v, -1); changed(); }, i === 0),
btn('▼', 'Lower priority', () => { move(v, 1); changed(); }, i === chosen.length - 1),
btn('✕', 'Remove', () => { chosen.splice(chosen.indexOf(v), 1); changed(); }),
);
list.appendChild(row);
});
if (!chosen.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing chosen yet.';
list.appendChild(none);
}
// The adder. Its own change bubbles to the field, which is what any
// dependent list listens for, so nothing extra is dispatched here.
const add = document.createElement('select');
add.className = 'multiselect-add';
const left = options.filter((opt) => !chosen.includes(optValue(opt)));
const head = document.createElement('option');
head.value = '';
head.textContent = left.length
? (spec.addLabel || 'Add one…')
: (options.length ? 'Everything is added' : 'Nothing to choose from yet');
add.appendChild(head);
add.disabled = !left.length;
const groups = new Map();
for (const opt of left) {
const o = document.createElement('option');
o.value = optValue(opt);
o.textContent = optLabel(opt);
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
if (!g) { add.appendChild(o); continue; }
if (!groups.has(g)) {
const og = document.createElement('optgroup');
og.label = g;
groups.set(g, og);
add.appendChild(og);
}
groups.get(g).appendChild(o);
}
add.addEventListener('change', () => {
if (!add.value) return;
chosen.push(add.value); // added last: lowest priority
redraw();
});
list.appendChild(add);
};
// The options last drawn, so a reorder or a pick can redraw without asking
// the module for its list again.
let lastOptions = Array.isArray(spec.options) ? spec.options : [];
const redraw = () => (orderable ? fillPicked(lastOptions) : fill(lastOptions));
// Moving or removing changes the value without any control firing, so say so.
const changed = () => {
redraw();
list.dispatchEvent(new Event('change', { bubbles: true }));
};
redraw();
if (spec.optionsRoute && moduleId) {
refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
if (Array.isArray(body?.options)) {
lastOptions = body.options;
redraw();
}
})
.catch(() => { /* keep the static/saved options */ });
};
if (!spec.optionsDependsOn) refetch();
}
field.append(span, list);
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
read = () => {
// With `orderable` the list IS the value, in the order it is shown.
if (orderable) return [...chosen];
syncChosen();
return [...list.querySelectorAll('input:checked')].map((input) => input.value);
};
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
@@ -655,6 +769,7 @@ function buildModuleCard(mod) {
const readers = new Map();
const controls = new Map(); // key → boolean/switch input (for showWhen)
const selects = new Map(); // key → select element (optionsDependsOn sources)
const sources = new Map(); // key → { el, value() } for optionsDependsOn, whatever the type
const dependents = []; // { field, when } to show/hide by a control's state
const optionDeps = []; // { refetch, on } selects whose options follow another field
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
@@ -712,6 +827,15 @@ function buildModuleCard(mod) {
readers.set(key, read);
if (control) controls.set(key, control);
if (selectEl) selects.set(key, selectEl);
// Anything can be the source of an optionsDependsOn — a multiselect
// hands over its values joined, which is what a module's route reads.
sources.set(key, {
el: selectEl || control || field,
value: () => {
const v = read();
return Array.isArray(v) ? v.join(',') : (typeof v === 'boolean' ? String(v) : String(v ?? ''));
},
});
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
@@ -736,9 +860,10 @@ function buildModuleCard(mod) {
// value as a query parameter, now and whenever K changes (a plan list
// that follows the chosen service type).
for (const dep of optionDeps) {
const src = selects.get(dep.on) || controls.get(dep.on);
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
if (src) src.addEventListener('change', run);
const src = sources.get(dep.on);
const run = () => dep.refetch({ [dep.on]: src ? src.value() : '' });
// A multiselect's switches are inside the field, so listen where they live.
if (src) src.el.addEventListener('change', run);
reloaders.push(run);
run();
}
@@ -962,8 +1087,16 @@ async function loadState() {
for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod));
}
if (!(state.modules || []).length) {
// A packaged ProdDash starts like this: the catalog below is the way in.
const none = document.createElement('div');
none.className = 'layouts-empty';
none.textContent = 'Nothing installed yet — pick from Available modules below.';
modulesList.appendChild(none);
}
renderPin(state);
loadTheme();
loadAlerts();
loadLayouts();
loadCatalog(false);
}
@@ -1065,7 +1198,143 @@ async function chooseTheme(id) {
}
}
// Follow a theme change made elsewhere (another admin page) live.
/* ── alerts ─────────────────────────────────────────────────────────── */
/* The two colours each alert level flashes between are a server-wide
choice, like the theme: GET /api/alerts lists them (public — dashboards
need them too), POST /api/admin/alerts saves them, and the server's
`alerts-config` shell event carries a change to every open dashboard and
any other admin page. The cadence per level is the shell's (style.css);
the swatch beside each row runs it, so a pair can be judged here before
it is tested on a real dashboard with the buttons below. */
const ALERT_LEVELS = [
{ id: 'low', name: 'Low', cadence: 'a slow fade between the two' },
{ id: 'medium', name: 'Medium', cadence: 'a faster fade' },
{ id: 'critical', name: 'Critical', cadence: 'no fade — it snaps between them' },
];
let alertColors = null; // level -> [a, b], as the server last said
const alertInputs = new Map(); // level -> [inputA, inputB]
function alertsMessage(text, bad = false) {
alertsStatus.textContent = text;
alertsStatus.className = 'admin-status' + (bad ? ' error' : text ? ' ok' : '');
}
function renderAlertLevels() {
alertLevelsEl.innerHTML = '';
alertInputs.clear();
for (const level of ALERT_LEVELS) {
const pair = alertColors?.[level.id] || ['#000000', '#000000'];
const row = document.createElement('div');
row.className = 'alert-level';
const preview = document.createElement('span');
preview.className = 'alert-preview ' + level.id;
preview.setAttribute('aria-hidden', 'true');
const name = document.createElement('div');
name.className = 'alert-level-name';
const strong = document.createElement('strong');
strong.textContent = level.name;
const cadence = document.createElement('span');
cadence.textContent = level.cadence;
name.append(strong, cadence);
const inputs = pair.map((value, i) => {
const input = document.createElement('input');
input.type = 'color';
input.value = value;
input.setAttribute('aria-label', `${level.name} colour ${i === 0 ? 'A' : 'B'}`);
input.title = `${level.name} — colour ${i === 0 ? 'A' : 'B'}`;
// The swatch follows the picker as it is dragged; the save waits for
// the picker to close, so a drag is one write rather than a hundred.
input.addEventListener('input', () => paintPreview());
input.addEventListener('change', () => saveAlertColors());
return input;
});
const paintPreview = () => {
preview.style.setProperty('--alert-a', inputs[0].value);
preview.style.setProperty('--alert-b', inputs[1].value);
};
paintPreview();
alertInputs.set(level.id, inputs);
row.append(preview, name, ...inputs);
alertLevelsEl.appendChild(row);
}
if (!alertTestChannel.options.length) {
for (let n = 1; n <= 6; n += 1) {
const opt = document.createElement('option');
opt.value = String(n);
opt.textContent = String(n);
alertTestChannel.appendChild(opt);
}
}
}
/** What the pickers say right now, in the server's shape. */
function readAlertColors() {
const out = {};
for (const [level, inputs] of alertInputs) out[level] = inputs.map((i) => i.value);
return out;
}
async function loadAlerts() {
let body;
try {
body = await api('/api/alerts');
} catch {
alertsSection.hidden = true; // a server without alerts — nothing to offer
return;
}
alertColors = body.colors || {};
renderAlertLevels();
alertsSection.hidden = false;
}
async function saveAlertColors() {
const colors = readAlertColors();
alertsMessage('');
try {
const body = await api('/api/admin/alerts', { method: 'POST', body: JSON.stringify({ colors }) });
alertColors = body.colors || colors;
alertsMessage('Saved — every open dashboard uses the new colours.');
} catch (e) {
alertsMessage(e.message, true);
if (e.authRequired) showLogin();
}
}
/** Another admin page changed the colours: follow, without yanking a picker out from under a hand. */
function applyAlertColorsEvent(colors) {
if (!colors || typeof colors !== 'object') return;
alertColors = colors;
for (const [level, inputs] of alertInputs) {
const pair = Array.isArray(colors[level]) ? colors[level] : null;
if (!pair) continue;
inputs.forEach((input, i) => {
if (document.activeElement !== input && /^#[0-9a-fA-F]{6}$/.test(String(pair[i] || ''))) input.value = pair[i];
});
inputs[0].dispatchEvent(new Event('input'));
}
}
for (const btn of document.querySelectorAll('.alert-test-btn')) {
btn.addEventListener('click', async () => {
const level = btn.dataset.level;
const channel = Number(alertTestChannel.value) || 1;
alertsMessage('');
btn.disabled = true;
try {
await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level, channel }) });
alertsMessage(`Test sent — dashboards listening on channel ${channel} are flashing ${level} for 8 seconds.`);
} catch (e) {
alertsMessage(e.message, true);
if (e.authRequired) showLogin();
} finally {
btn.disabled = false;
}
});
}
// Follow a theme or alert-colour change made elsewhere (another admin page) live.
let themeEvents = null;
let themeEventsRetry = null;
@@ -1080,6 +1349,9 @@ function subscribeThemeEvents() {
applyTheme(theme);
renderThemes();
});
themeEvents.addEventListener('alerts-config', (e) => {
try { applyAlertColorsEvent(JSON.parse(e.data).colors); } catch { /* a malformed frame changes nothing */ }
});
themeEvents.onerror = () => {
if (themeEvents.readyState === EventSource.CLOSED) {
clearTimeout(themeEventsRetry);
@@ -1163,6 +1435,7 @@ function renderAvailable(cat) {
els.updateSlot.appendChild(btn);
}
const available = cat.available || [];
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
if (!available.length) {
const none = document.createElement('div');
none.className = 'layouts-empty';
@@ -1205,11 +1478,46 @@ function renderAvailable(cat) {
btn.disabled = !item.compatible;
btn.title = item.compatible ? '' : item.reason;
btn.addEventListener('click', () => installFromCatalog(item, btn));
if (item.compatible) {
const pick = document.createElement('input');
pick.type = 'checkbox';
pick.className = 'catalog-pick';
pick.dataset.id = item.id;
pick.dataset.name = item.name;
pick.title = 'Tick several, then Install selected';
row.append(pick);
}
row.append(main, btn);
availableList.appendChild(row);
}
}
const installSelectedBtn = document.getElementById('install-selected');
/** The ticked modules, in one request; the answer says which ones landed. */
async function installSelected() {
const picks = [...availableList.querySelectorAll('.catalog-pick:checked')];
if (!picks.length) return toast('Tick the modules to install first', true);
installSelectedBtn.disabled = true;
installSelectedBtn.textContent = `Installing ${picks.length}…`;
try {
const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) });
const done = (r.results || []).filter((x) => x.ok).length;
const failed = (r.results || []).filter((x) => !x.ok);
toast(failed.length
? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}`
: `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0);
await loadState();
} catch (e) {
if (e.authRequired) showLogin();
else toast(e.message, true);
} finally {
installSelectedBtn.disabled = false;
installSelectedBtn.textContent = 'Install selected';
}
}
installSelectedBtn.addEventListener('click', installSelected);
async function installFromCatalog(item, btn) {
btn.disabled = true;
const label = btn.textContent;
+22
View File
@@ -58,6 +58,7 @@
<section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1>
<div class="catalog-toolbar">
<button class="btn small primary" id="install-selected" hidden>Install selected</button>
<button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span>
</div>
@@ -69,6 +70,27 @@
<div id="layouts-list"></div>
</section>
<section id="alerts-section" hidden>
<details class="admin-fold">
<summary class="admin-h1 admin-fold-title">Alerts</summary>
<p class="admin-copy">A module that sees something a tech must act on raises an alert on one of
six channels, and every dashboard listening to that channel flashes — menu bar and screen
edge — with the alert's text across the bar. Each level alternates between two colours;
pick them here. A dashboard chooses its channels in its Layouts menu, under Alerts.</p>
<div class="admin-card">
<div class="alert-levels" id="alert-levels"></div>
<div class="alert-test">
<span class="alert-test-label">Test on channel</span>
<select id="alert-test-channel" class="alert-test-select" aria-label="Channel to test on"></select>
<button class="btn small alert-test-btn" data-level="low">Low</button>
<button class="btn small alert-test-btn" data-level="medium">Medium</button>
<button class="btn small alert-test-btn" data-level="critical">Critical</button>
</div>
<div class="admin-status" id="alerts-status"></div>
</div>
</details>
</section>
<section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p>
+7
View File
@@ -17,6 +17,13 @@
<header id="topbar">
<span id="brand">Prod<span class="accent">Dash</span></span>
<!-- An alert this dashboard listens to: its text, bold, centred in the bar (shell.js fills it). -->
<div id="alert-banner" role="status" aria-live="assertive" hidden>
<span class="alert-channel" aria-hidden="true"></span>
<span class="alert-text"></span>
<span class="alert-more"></span>
</div>
<span class="spacer"></span>
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
+445 -22
View File
@@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
/* ── module registry ────────────────────────────────────────────────── */
let registry = new Map(); // module id -> manifest from GET /api/modules
/* The installed modules that are not running — disabled, or needing a newer
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
to say why a tile one of them left in the layout is not drawn. */
let inactiveModules = new Map();
/** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything
@@ -208,6 +212,7 @@ async function loadRegistry() {
if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m]));
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
return true;
} catch {
return false;
@@ -227,6 +232,16 @@ const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
away regardless; this is the windowed equivalent, asked for by hand. */
let autoHideHeads = false;
/* Which of the six alert channels this dashboard listens to. Which screen
flashes for what is part of how a booth is arranged — the stage screen
takes the stage's alerts, the video world its own — so like auto-hide it
saves with the layout, local and named. Every dashboard hears every alert
over the shell stream; this decides which ones it shows (see the alerts
section). */
let alertChannels = [];
/** True while an alert this dashboard listens to is on screen: the menu bar is held open for it. */
let alertShowing = false;
/* Beside the layout itself: the copy it replaced (restored if the layout
ever fails to parse) and, after loading a named layout, the arrangement
that was on screen before (what Undo goes back to). */
@@ -235,7 +250,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
function saveLayout() {
try {
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, alertChannels, tiles });
const current = localStorage.getItem(LS_LAYOUT);
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
localStorage.setItem(LS_LAYOUT, next);
@@ -477,7 +492,9 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
registry offers them again: a Service timer waits for PCO Plan, a
transcript for its channel. Never the module's default in their place —
a wall of "All timers" where five different timers were is worse than
five gaps that fill themselves in. */
five gaps that fill themselves in. For the one that is not coming back
(a module stopped offering that kind of tile) the Layouts menu lists them
under Not shown, each with a ✕ — the only way to let its cells go. */
function tileIsOffered(tile) {
const man = registry.get(tile.module);
@@ -510,6 +527,8 @@ function reconcileOfferedTiles() {
}
if (changed) rebuildDividers();
scheduleOfferedRecheck();
refreshEmptyState(); // the registry may have gone from nothing to something
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
}
/* A module's entry list can change without anyone saving admin config: PCO
@@ -529,10 +548,90 @@ function scheduleOfferedRecheck() {
}, OFFERED_RECHECK_MS);
}
/** The tiles a layout holds that the board is not showing, each with what
to call it and, in a few words, why it is not drawn. */
function unshownTiles() {
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
const man = registry.get(tile.module);
if (man) {
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
return { tile, title, reason: 'not offered right now' };
}
const off = inactiveModules.get(tile.module);
if (off) {
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
return { tile, title: tile.title || off.name || tile.module, reason };
}
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
});
}
/** The tiles a layout holds that the board is not showing, by name. */
function unshownTileNames() {
return tiles.filter((t) => !tileEls.has(t.id))
.map((t) => t.title || registry.get(t.module)?.name || t.module);
return unshownTiles().map((u) => u.title);
}
/* Repaints the Layouts menu's Not shown section while the menu is open —
set by unshownSection(), called whenever tiles become offered or stop
being, so the list never lags the board behind it. */
let repaintUnshown = () => {};
/** The Layouts menu's Not shown section: one row per tile the layout holds
that the board is not drawing — its name, why in a word or two, and a ✕
that removes it — present only while there is one. A tile that waits for
its module (a Service timer before PCO Plan is up) is listed but can stay;
this exists for the one that will never be back, which would otherwise
hold its cells with no control anywhere to let them go. */
function unshownSection() {
const wrap = document.createElement('div');
wrap.className = 'menu-unshown';
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
let painted = '';
const paint = () => {
wrap.innerHTML = '';
const list = unshownTiles();
painted = signature(list);
wrap.hidden = !list.length;
if (!list.length) return;
const divider = document.createElement('div');
divider.className = 'menu-divider';
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Not shown';
wrap.append(divider, title);
for (const { tile, title: name, reason } of list) {
const row = document.createElement('div');
row.className = 'menu-item menu-unshown-row';
const label = document.createElement('span');
label.textContent = name;
const sub = document.createElement('span');
sub.className = 'sub';
sub.textContent = reason;
label.appendChild(sub);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'menu-x';
remove.setAttribute('data-tip', 'Remove');
remove.setAttribute('aria-label', `Remove ${name}`);
remove.innerHTML = CLOSE_SVG;
remove.addEventListener('click', (e) => {
// The row goes with the tile, so the click must not reach the
// document as one from outside the menu — the menu stays open
// to show the next row (or none), as the stepper and Alerts do.
e.stopPropagation();
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
paint();
});
row.append(label, remove);
wrap.appendChild(row);
}
};
paint();
// Only when something changed: the registry is re-read every few seconds
// while a tile is folded away, and rows rebuilt under the pointer for
// nothing would lose the tooltip (and the click) the operator was on.
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
return wrap;
}
function buildTile(tile) {
@@ -639,8 +738,10 @@ function buildTile(tile) {
el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
// — neither is a moment to take the bar away.
// — neither is a moment to take the bar away. Nor is an open header
// menu, which hangs below the bar and is about to be pointed at.
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return;
const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that
@@ -651,6 +752,7 @@ function buildTile(tile) {
el.addEventListener('pointerleave', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
el.classList.remove('head-peek');
});
@@ -658,7 +760,7 @@ function buildTile(tile) {
applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null };
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
tileEls.set(tile.id, entry);
applyRect(tile);
@@ -676,6 +778,15 @@ function buildTile(tile) {
function refreshEmptyState() {
emptyEl.hidden = tiles.length > 0;
if (emptyEl.hidden) return;
// Two different empties: a bare board, and a ProdDash with nothing to put
// on one — a packaged install starts without modules until Admin adds some.
const noModules = registry.size === 0;
if (noModules === emptyEl.classList.contains('no-modules')) return;
emptyEl.classList.toggle('no-modules', noModules);
emptyEl.innerHTML = noModules
? 'No modules yet — <a href="/admin">open Admin</a> and install some under Available modules.'
: 'No tiles yet — use “+ Add tile” to build this dashboard.<br>Every browser keeps its own layout.';
}
/* ── drag to move ───────────────────────────────────────────────────── */
@@ -1375,6 +1486,34 @@ function buildModuleApi(tile, man, entry) {
entry.dot.title = msg;
},
/**
* Flash every dashboard listening to an alert channel — this one too,
* if it listens. { channel: 1–6, level: 'low' | 'medium' | 'critical',
* text (80 chars at most), duration: seconds, 0 to hold it until
* cleared, 15 when omitted }. Goes through the server, which is the
* hub; resolves with the alert as raised, rejects with the reason it
* was refused. A module with a server entry raises from there instead
* (shell.alert) — see docs/MODULE-GUIDE.md, "Raising alerts".
*/
async alert(opts = {}) {
const res = await fetch('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...opts, source: man.id }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Alert refused (${res.status})`);
return body.alert;
},
/** End the alert on a channel early. One alert per channel, so this ends whatever is on it. */
async clearAlert(channel) {
const res = await fetch('/api/alerts/' + encodeURIComponent(String(Number(channel))), { method: 'DELETE' });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Clear refused (${res.status})`);
return Boolean(body.cleared);
},
/**
* The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The
@@ -1416,34 +1555,60 @@ function buildModuleApi(tile, man, entry) {
menu.className = 'tile-menu';
menu.hidden = true;
/* The menu lives on the body, not in the bar. A folded-away bar
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
and either one makes a fixed-positioned descendant position
itself against the bar instead of the viewport — the menu landed
a screen-height below its button, inside the tile's overflow, and
never showed. Its host carries the tile's data-module so the
module's scoped CSS still reaches the items. */
const host = document.createElement('div');
host.className = 'tile-menu-host';
if (man) host.dataset.module = man.id;
host.appendChild(menu);
const setOpen = (on) => {
if (on === !menu.hidden) return;
menu.hidden = !on;
btn.classList.toggle('is-open', on);
if (on) entry.openMenus.add(menu);
else entry.openMenus.delete(menu);
// an open menu holds a folded-away bar out; when the last one
// closes with the pointer elsewhere, the bar goes back
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
};
const open = () => {
if (typeof build === 'function') {
menu.innerHTML = '';
build(menu);
}
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
setOpen(true);
// under the button, right-aligned, kept whole inside the viewport
const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px';
menu.style.top = '0px';
const w = menu.offsetWidth;
const h = menu.offsetHeight;
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open();
else menu.hidden = true;
else setOpen(false);
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn, menu);
wrap.append(btn);
entry.controls.appendChild(wrap);
document.body.appendChild(host);
headerEls.add(wrap);
headerEls.add(host);
return {
button: btn,
menu,
@@ -1451,7 +1616,7 @@ function buildModuleApi(tile, man, entry) {
btn.textContent = text;
},
close() {
menu.hidden = true;
setOpen(false);
},
};
},
@@ -1463,6 +1628,8 @@ function buildModuleApi(tile, man, entry) {
for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove();
headerEls.clear();
entry.openMenus.clear();
entry.el.classList.remove('menu-open');
for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear();
},
@@ -2101,7 +2268,10 @@ function renderLayout(layout) {
// Density first: the tiles are clamped and placed against it.
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
if (!Array.isArray(layout)) {
setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
setAlertChannels(layout?.alertChannels, { save: false });
}
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
scheduleOfferedRecheck();
refreshEmptyState();
@@ -2166,9 +2336,12 @@ function renderAddMenu(menu) {
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = '';
// Density first: the one thing in here that is not a layout but the board
// the layouts are arranged on.
// The board first — density, and which alert channels this screen
// listens to: the two things in here that are not a layout but part of
// how one is arranged.
menu.appendChild(gridStepper());
menu.appendChild(alertsFold());
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider);
@@ -2204,7 +2377,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, alertChannels, tiles } }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -2260,7 +2433,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
const note = unshown.length
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
: '';
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000,
@@ -2298,15 +2471,25 @@ const topbarSizer = new ResizeObserver(() => {
});
topbarSizer.observe(topbar);
/* What the notch (or fullscreen) asked for. The bar itself is also held
open for as long as an alert this dashboard listens to is showing — the
alert's text lives in it — and goes back to this the moment it ends. */
let menuHiddenWanted = false;
function paintMenuHidden() {
document.body.classList.toggle('menu-hidden', menuHiddenWanted && !alertShowing);
}
function setMenuHidden(hidden, { persist = true } = {}) {
document.body.classList.toggle('menu-hidden', hidden);
menuHiddenWanted = Boolean(hidden);
paintMenuHidden();
if (persist) {
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
}
}
topbarNotch.addEventListener('click', () => {
setMenuHidden(!document.body.classList.contains('menu-hidden'));
setMenuHidden(!menuHiddenWanted);
});
try {
@@ -2449,7 +2632,7 @@ document.addEventListener('fullscreenchange', () => {
if (active) {
closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
menuHiddenBeforeFs = menuHiddenWanted;
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
@@ -2478,9 +2661,15 @@ new ResizeObserver(() => {
function subscribeEvents() {
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
sseSubscribe('/api/events', {
// (Re)connected: frames may have been missed, so the alerts picture is
// taken whole again — what is active, and the colours.
open: () => { refreshAlerts(); },
events: {
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
theme: handleThemeEvent,
alert: handleAlertEvent,
'alert-clear': handleAlertClearEvent,
'alerts-config': handleAlertsConfigEvent,
},
});
}
@@ -2550,6 +2739,240 @@ function initTheme() {
initTheme();
/* ── alerts ─────────────────────────────────────────────────────────── */
/* A module that sees something a tech must act on now raises an alert on
one of six channels (server.js is the hub; docs/ALERTS.md the whole
story). Every dashboard hears every alert over the shell stream and shows
the ones on channels it listens to: the menu bar — held open for the
duration, whatever its notch says — and a frame round the screen flash
between the level's two colours (style.css sets the cadence per level;
critical floods the board behind the tiles as well, in a muted cut), with
the text bold in the middle of the bar. Several at once: the most
severe takes the bar, the newest among equals, and a count says how many
more are on. The server times alerts out and says so; a timer here ends
one on time anyway, in case that frame is missed on a flaky connection. */
const ALERT_CHANNELS = 6;
const ALERT_LEVEL_RANK = { low: 1, medium: 2, critical: 3 };
const ALERT_COLORS_DEFAULT = {
low: ['#1f6feb', '#0b2a5c'],
medium: ['#f0b429', '#6b4a0a'],
critical: ['#f4433c', '#1a0505'],
};
const activeAlerts = new Map(); // channel -> { id, channel, level, text, source, startedAt, endsAt (this clock) | null }
let alertColors = ALERT_COLORS_DEFAULT;
let alertEndTimer = null;
const alertBanner = document.getElementById('alert-banner');
/** Whole numbers 1–6, each once, in order — whatever a layout file says. */
function normalizeAlertChannels(list) {
const out = new Set();
for (const v of Array.isArray(list) ? list : []) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1 && n <= ALERT_CHANNELS) out.add(n);
}
return [...out].sort((a, b) => a - b);
}
function setAlertChannels(list, { save = true } = {}) {
alertChannels = normalizeAlertChannels(list);
paintAlerts();
if (save) saveLayout();
}
function toggleAlertChannel(n) {
setAlertChannels(alertChannels.includes(n)
? alertChannels.filter((c) => c !== n)
: [...alertChannels, n]);
}
const CARET_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
let alertsFoldOpen = false;
/** The Layouts menu's Alerts row. Folded, it says which channels this
dashboard listens to; open, six numbered buttons toggle them without
closing the menu, so a change can be watched taking effect. */
function alertsFold() {
const wrap = document.createElement('div');
wrap.className = 'menu-fold';
const head = document.createElement('button');
head.type = 'button';
head.className = 'menu-item menu-fold-head';
const label = document.createElement('span');
label.textContent = 'Alerts';
const sub = document.createElement('span');
sub.className = 'sub';
label.appendChild(sub);
const caret = document.createElement('span');
caret.className = 'menu-fold-caret';
caret.innerHTML = CARET_SVG;
head.append(label, caret);
const body = document.createElement('div');
body.className = 'menu-fold-body alert-channels';
const buttons = [];
for (let n = 1; n <= ALERT_CHANNELS; n += 1) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'alert-channel-btn';
btn.textContent = String(n);
btn.setAttribute('data-tip', `Channel ${n}`);
btn.addEventListener('click', () => {
toggleAlertChannel(n);
paint();
});
buttons.push(btn);
body.appendChild(btn);
}
const paint = () => {
sub.textContent = alertChannels.length ? 'Channels ' + alertChannels.join(', ') : 'No channels';
head.setAttribute('aria-expanded', alertsFoldOpen ? 'true' : 'false');
wrap.classList.toggle('is-open', alertsFoldOpen);
body.hidden = !alertsFoldOpen;
buttons.forEach((btn, i) => {
const on = alertChannels.includes(i + 1);
btn.classList.toggle('is-on', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
};
head.addEventListener('click', () => {
alertsFoldOpen = !alertsFoldOpen;
paint();
});
paint();
wrap.append(head, body);
return wrap;
}
/** Remember an alert the server announced, its end put on this browser's clock. */
function noteAlert(a) {
const channel = Number(a?.channel);
if (!Number.isInteger(channel) || channel < 1 || channel > ALERT_CHANNELS) return;
const remaining = a.remainingMs === null || a.remainingMs === undefined ? null : Number(a.remainingMs);
activeAlerts.set(channel, {
id: String(a.id || ''),
channel,
level: ALERT_LEVEL_RANK[a.level] ? String(a.level) : 'low',
text: String(a.text || ''),
source: String(a.source || ''),
startedAt: Number(a.startedAt) || Date.now(),
endsAt: Number.isFinite(remaining) ? Date.now() + Math.max(0, remaining) : null,
});
}
/** `alert` shell event: a new alert, or a replacement on its channel. */
function handleAlertEvent(e) {
try {
noteAlert(JSON.parse(e.data));
} catch {
return;
}
paintAlerts();
}
/** `alert-clear` shell event: ended early or timed out. */
function handleAlertClearEvent(e) {
let payload;
try {
payload = JSON.parse(e.data);
} catch {
return;
}
const current = activeAlerts.get(Number(payload?.channel));
if (!current) return;
// A clear for an alert already replaced leaves the replacement alone.
if (payload.id && current.id && payload.id !== current.id) return;
activeAlerts.delete(current.channel);
paintAlerts();
}
/** `alerts-config` shell event: an admin changed the colours. */
function handleAlertsConfigEvent(e) {
try {
setAlertColors(JSON.parse(e.data).colors);
} catch { /* a malformed frame changes nothing */ }
}
function setAlertColors(colors) {
const next = {};
for (const level of Object.keys(ALERT_COLORS_DEFAULT)) {
const pair = Array.isArray(colors?.[level]) ? colors[level] : [];
next[level] = ALERT_COLORS_DEFAULT[level].map((fallback, i) => {
const v = String(pair[i] ?? '').trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : fallback;
});
}
alertColors = next;
paintAlerts();
}
/** The whole picture from the server: what is active, and the colours. */
async function refreshAlerts() {
try {
const res = await fetchWithTimeout('/api/alerts', 8000);
if (!res.ok) return;
const body = await res.json();
activeAlerts.clear();
for (const a of body.active || []) noteAlert(a);
setAlertColors(body.colors); // paints
} catch { /* server away — what is known stands */ }
}
/** Relative luminance of #rrggbb, 0 (black) to 1 (white). */
function luminance(hex) {
const n = parseInt(String(hex).slice(1), 16);
const chan = (c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * chan((n >> 16) & 255) + 0.7152 * chan((n >> 8) & 255) + 0.0722 * chan(n & 255);
}
/** Black or white for the banner — whichever reads against BOTH colours the bar flashes between. */
function alertTextColor(a, b) {
const contrast = (l1, l2) => (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
const la = luminance(a);
const lb = luminance(b);
const white = Math.min(contrast(1, la), contrast(1, lb));
const black = Math.min(contrast(0, la), contrast(0, lb));
return white >= black ? '#ffffff' : '#000000';
}
function paintAlerts() {
clearTimeout(alertEndTimer);
alertEndTimer = null;
const now = Date.now();
for (const [channel, a] of activeAlerts) {
if (a.endsAt !== null && a.endsAt <= now) activeAlerts.delete(channel);
}
const showing = [...activeAlerts.values()]
.filter((a) => alertChannels.includes(a.channel))
.sort((a, b) => (ALERT_LEVEL_RANK[b.level] - ALERT_LEVEL_RANK[a.level]) || (b.startedAt - a.startedAt));
const top = showing[0];
const root = document.documentElement.style;
if (!top) {
delete document.body.dataset.alert;
alertBanner.hidden = true;
} else {
const [a, b] = alertColors[top.level] || ALERT_COLORS_DEFAULT.low;
root.setProperty('--alert-a', a);
root.setProperty('--alert-b', b);
root.setProperty('--alert-text', alertTextColor(a, b));
document.body.dataset.alert = top.level;
alertBanner.querySelector('.alert-channel').textContent = String(top.channel);
alertBanner.querySelector('.alert-text').textContent = top.text;
alertBanner.querySelector('.alert-more').textContent = showing.length > 1 ? `+${showing.length - 1}` : '';
alertBanner.hidden = false;
}
alertShowing = Boolean(top);
paintMenuHidden();
// The next end among everything active — shown or not, since a channel
// may be subscribed to meanwhile — repaints the picture when it comes.
let next = Infinity;
for (const a of activeAlerts.values()) if (a.endsAt !== null) next = Math.min(next, a.endsAt);
if (next < Infinity) alertEndTimer = setTimeout(paintAlerts, Math.max(50, next - now + 20));
}
/* ── boot ───────────────────────────────────────────────────────────── */
async function boot() {
+173 -2
View File
@@ -36,6 +36,7 @@ button { font-family: inherit; }
/* ── topbar ────────────────────────────────────────────────────────── */
#topbar {
position: relative; /* the alert banner centres itself against the bar */
flex: none;
display: flex;
align-items: center;
@@ -135,6 +136,110 @@ body.menu-hidden #topbar {
color: var(--accent-text);
}
/* ── alerts ────────────────────────────────────────────────────────── */
/* An alert this dashboard listens to (shell.js sets body[data-alert] to its
level and --alert-a / --alert-b to the level's two colours, from Admin →
Alerts) flashes the shell's chrome: the menu bar's background and a frame
round the whole screen swap between the two colours, with the alert's text
bold in the middle of the bar. Low breathes slowly, medium pulses,
critical snaps between the two with no fade at all — and, alone among
them, floods the board behind the tiles too (below). Low and medium leave
the tiles alone: the frame is what carries their flash on a fullscreen
wall, where the bar (held open for the alert) is the only other chrome. */
@keyframes alert-flash {
0%, 100% { background-color: var(--alert-a); }
50% { background-color: var(--alert-b); }
}
@keyframes alert-frame {
0%, 100% { border-color: var(--alert-a); }
50% { border-color: var(--alert-b); }
}
body[data-alert="low"] { --alert-period: 3.2s; --alert-ease: ease-in-out; }
body[data-alert="medium"] { --alert-period: 1.1s; --alert-ease: ease-in-out; }
body[data-alert="critical"] { --alert-period: 0.6s; --alert-ease: steps(1, end); }
body[data-alert] #topbar {
border-bottom-color: transparent;
animation: alert-flash var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
}
body[data-alert]::after {
content: "";
position: fixed;
inset: 0;
z-index: 85; /* over tiles and menus, under modals and the toast */
pointer-events: none;
border: 6px solid var(--alert-a);
animation: alert-frame var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
}
/* The bar's buttons keep their own surface so they stay legible on either colour. */
body[data-alert] #topbar .tb-btn { border-color: transparent; }
/* Critical reaches the whole page. The board behind the tiles flashes too,
in a cut of the two colours mixed well into the page background — shifted
toward it, so a tile still stands off the board — and the tiles' own
panel thins a little to let that show through them, faintly enough that
nothing written on a tile loses contrast. On a wall where tiles cover
every pixel this is what carries the flash to the middle of the screen. */
@keyframes alert-board {
0%, 100% { background-color: color-mix(in srgb, var(--alert-a) 40%, var(--bg)); }
50% { background-color: color-mix(in srgb, var(--alert-b) 40%, var(--bg)); }
}
body[data-alert="critical"] {
animation: alert-board var(--alert-period) var(--alert-ease) infinite;
}
body[data-alert="critical"] .tile {
background: color-mix(in srgb, var(--panel) 88%, transparent);
}
body[data-alert="critical"] .tile.is-tinted {
background: color-mix(in srgb, color-mix(in srgb, var(--tile-tint) 14%, var(--panel)) 88%, transparent);
}
#alert-banner {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 10px;
/* clear of the wordmark at the left and the buttons at the right */
max-width: clamp(140px, calc(100% - 420px), 70vw);
pointer-events: none;
color: var(--alert-text, #fff);
font-weight: 800;
font-size: 17px;
letter-spacing: 0.02em;
white-space: nowrap;
}
#alert-banner[hidden] { display: none; }
#alert-banner .alert-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* which channel — small, boxed, before the text */
#alert-banner .alert-channel {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 2px solid currentColor;
border-radius: 6px;
font-size: 12px;
line-height: 1;
}
/* "+1" when more alerts are on than the bar shows */
#alert-banner .alert-more {
flex: none;
font-size: 12px;
font-weight: 700;
opacity: 0.85;
}
#alert-banner .alert-more:empty { display: none; }
/* ── dropdown menus ────────────────────────────────────────────────── */
.dropdown { position: relative; }
@@ -217,6 +322,43 @@ body.menu-hidden #topbar {
text-align: center;
font-variant-numeric: tabular-nums;
}
/* the Layouts menu's Alerts row: a fold that opens to six channel buttons */
.menu-fold-head { justify-content: space-between; }
.menu-fold-head > span:first-child { flex: 1; }
.menu-fold-caret {
flex: none;
display: inline-flex;
color: var(--muted);
transition: transform 0.15s;
}
.menu-fold-caret svg { width: 12px; height: 12px; display: block; }
.menu-fold.is-open .menu-fold-caret { transform: rotate(90deg); }
.menu-fold-body[hidden] { display: none; }
.alert-channels {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
padding: 2px 10px 10px;
}
.alert-channel-btn {
height: 30px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.alert-channel-btn:hover { border-color: var(--muted); }
.alert-channel-btn.is-on,
.alert-channel-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
.menu-title {
padding: 6px 10px 3px;
color: var(--muted);
@@ -226,6 +368,33 @@ body.menu-hidden #topbar {
text-transform: uppercase;
}
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
is not drawing, why in a word or two, and the ✕ that removes it */
.menu-unshown[hidden] { display: none; }
.menu-unshown-row { cursor: default; }
.menu-unshown-row:hover { background: none; }
.menu-unshown-row > span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-x {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 5px;
color: var(--muted);
cursor: pointer;
}
.menu-x:hover { background: var(--border); color: var(--danger); }
.menu-x svg { width: 12px; height: 12px; display: block; }
/* ── grid ──────────────────────────────────────────────────────────── */
#gridwrap {
@@ -445,8 +614,9 @@ body.menu-hidden #topbar {
}
.tile-dd { position: relative; display: inline-flex; }
/* a module header menu — fixed-positioned by the shell so the tile's
overflow:hidden can't clip it */
/* a module header menu — hosted on the body (shell.js addMenu) so neither
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
.tile-menu-host { display: contents; }
.tile-menu {
position: fixed;
z-index: 70;
@@ -979,6 +1149,7 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; }
#alert-banner { font-size: 14px; gap: 6px; }
}
/* ── themes ────────────────────────────────────────────────────────── */
+449 -5
View File
@@ -277,6 +277,52 @@ function isUninstalled(id) {
return moduleState(id).uninstalled === true;
}
/**
* A packaged install that still carries modules in its app folder — every
* install made before 1.10.0 does — moves them into the data directory
* once, as installed copies, and drops the app-folder copies. Nothing
* changes for the operator: the same modules run with the same settings
* (config is keyed by module id), and from now on each updates on its own
* from the catalog. A compiled helper sitting in a bundled folder travels
* with it. A module the operator had uninstalled is simply dropped — the
* catalog still offers it, and its settings stay in modules.json.
*/
function unbundleModules() {
if (!PACKAGED || !fs.existsSync(BUNDLED_MODULES_DIR)) return;
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
let moved = 0;
let failed = 0;
for (const man of readManifests(BUNDLED_MODULES_DIR, 'bundled')) {
if (isUninstalled(man.id)) continue;
const copy = installed.get(man.id);
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the data directory already has as good or better
const dest = path.join(INSTALLED_MODULES_DIR, man.id);
const staging = path.join(INSTALLED_MODULES_DIR, `.${man.id}.moving`);
try {
fs.mkdirSync(INSTALLED_MODULES_DIR, { recursive: true });
fs.rmSync(staging, { recursive: true, force: true });
fs.cpSync(man.dir, staging, { recursive: true }); // keeps modes: a compiled helper stays executable
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
moved += 1;
console.log(`[proddash] moved bundled module "${man.id}" v${man.version} into the data directory${copy ? ` (over the installed v${copy.version})` : ''}`);
} catch (err) {
failed += 1;
console.error(`[proddash] could not move bundled module "${man.id}" into the data directory:`, err?.message || err);
}
}
if (failed) {
console.warn(`[proddash] ${failed} bundled module${failed === 1 ? '' : 's'} stay in the app folder for now — tried again at the next start`);
return;
}
try {
fs.rmSync(BUNDLED_MODULES_DIR, { recursive: true, force: true });
console.log(`[proddash] the app folder carries no modules now${moved ? ` — ${moved} moved into the data directory` : ''}; the admin page's catalog installs and updates them`);
} catch (err) {
console.warn("[proddash] could not remove the app folder's modules:", err?.message || err);
}
}
function moduleState(id) {
const s = modulesConfig[id];
return s && typeof s === 'object' ? s : {};
@@ -373,12 +419,29 @@ function mountModule(id) {
const log = makeLog(id);
try {
const serverPath = path.join(man.dir, man.server);
// Re-require fresh config on every (re)mount; the module code itself stays cached.
// Drop the module's code from the require cache so every (re)mount runs
// the files on disk. Without this a module UPDATE swaps the folder while
// the process keeps executing the old code until ProdDash itself
// restarts — the manifest (read fresh from disk) then reports a version
// the running code isn't, and anything new in the update silently
// doesn't happen. Modules already have to come up clean on re-init
// (module guide), so a fresh load on config remounts is the same
// contract without the trap.
const cachePrefix = man.dir.endsWith(path.sep) ? man.dir : man.dir + path.sep;
for (const key of Object.keys(require.cache)) {
if (key.startsWith(cachePrefix)) delete require.cache[key];
}
const mod = require(serverPath);
const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
// routes over loopback, chiefly — without guessing.
const shell = { port: PORT };
// routes over loopback, chiefly — without guessing. alert() and
// clearAlert() flash the dashboards listening to a channel (see the
// alerts section); an alert this module raises ends when it stops.
const shell = {
port: PORT,
alert: (opts) => raiseAlert(opts, id),
clearAlert: (channel) => clearAlert(channel),
};
if (typeof mod.init === 'function') {
entry.handle = mod.init({ config, log, shell }) || null;
}
@@ -403,6 +466,9 @@ function unmountModule(id) {
} catch (err) {
console.error(`[proddash] module "${id}" failed to stop cleanly:`, err);
}
// Its alerts go with it: a "held until cleared" alert from a module that
// is no longer running would otherwise flash for ever.
clearAlertsFrom(id);
mounted.delete(id);
}
@@ -759,6 +825,189 @@ function setTheme(id) {
return themeView();
}
/* ── alerts ─────────────────────────────────────────────────────────── */
/* A module that notices something a tech must see now — a timer overrun, a
meter past its limit, a feed gone quiet — raises an alert, and every
dashboard subscribed to its channel flashes: the menu bar and a frame
round the screen alternate between two colours, with the alert's text
bold in the middle of the bar. Six channels for now, so a booth can send
the stage's alerts and the video world's to different screens. The
server is the hub: it holds what is active (one alert per channel — a
new one replaces the last), times them out, tells open dashboards over
the shell stream (`alert`, `alert-clear`), and answers GET /api/alerts
for a dashboard that opens or reconnects mid-alert. The level sets the
cadence (shell.js / style.css) and the two colours, chosen in Admin →
Alerts and kept as `alerts.colors` in this machine's proddash.json. */
const ALERT_LEVELS = ['low', 'medium', 'critical'];
const ALERT_CHANNELS = 6;
const ALERT_TEXT_MAX = 80;
const ALERT_DURATION_DEFAULT_S = 15;
const ALERT_DURATION_MAX_S = 600;
const ALERT_SOURCE_RE = /^[A-Za-z0-9_-]{1,40}$/;
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
const DEFAULT_ALERT_COLORS = {
low: ['#1f6feb', '#0b2a5c'],
medium: ['#f0b429', '#6b4a0a'],
critical: ['#f4433c', '#1a0505'],
};
/** A full level → [colourA, colourB] set; anything missing or malformed takes the default. */
function normalizeAlertColors(raw) {
const out = {};
for (const level of ALERT_LEVELS) {
const pair = Array.isArray(raw?.[level]) ? raw[level] : [];
out[level] = DEFAULT_ALERT_COLORS[level].map((fallback, i) => {
const v = String(pair[i] ?? '').trim();
return HEX_COLOR_RE.test(v) ? v.toLowerCase() : fallback;
});
}
return out;
}
/** The channel list a dashboard listens to: whole numbers 1–6, each once, in order. */
function sanitizeAlertChannels(list) {
const out = new Set();
for (const v of Array.isArray(list) ? list : []) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1 && n <= ALERT_CHANNELS) out.add(n);
}
return [...out].sort((a, b) => a - b);
}
let alertColors = normalizeAlertColors(shellConfig.alerts?.colors);
const activeAlerts = new Map(); // channel -> { id, channel, level, text, source, startedAt, duration, endsAt, timer }
let alertSeq = 0;
/** One alert as pages see it. `remainingMs` is what a browser counts down
from — its clock and the server's need not agree. */
function alertView(a) {
return {
id: a.id,
channel: a.channel,
level: a.level,
text: a.text,
source: a.source,
startedAt: a.startedAt,
duration: a.duration,
endsAt: a.endsAt,
remainingMs: a.endsAt ? Math.max(0, a.endsAt - Date.now()) : null,
};
}
/** What GET /api/alerts returns — public, since dashboards need it before anyone logs in. */
function alertsView() {
return {
channels: ALERT_CHANNELS,
levels: ALERT_LEVELS.slice(),
colors: normalizeAlertColors(alertColors),
active: [...activeAlerts.values()].sort((a, b) => a.channel - b.channel).map(alertView),
};
}
/**
* Raise an alert: check it, replace whatever its channel was showing, time
* it out, and tell every open dashboard. `source` is where it came from —
* a module id (that module's alerts end when it stops), or 'admin' / 'api'.
* Throws an httpError for bad input; a route answers 400 with its message,
* a module gets it as a plain exception.
*/
function raiseAlert(input, source) {
const opts = input && typeof input === 'object' ? input : {};
const channel = Number(opts.channel);
if (!Number.isInteger(channel) || channel < 1 || channel > ALERT_CHANNELS) {
throw httpError(400, `channel must be a whole number from 1 to ${ALERT_CHANNELS}.`);
}
const level = String(opts.level ?? '').toLowerCase();
if (!ALERT_LEVELS.includes(level)) {
throw httpError(400, `level must be one of ${ALERT_LEVELS.join(', ')}.`);
}
const text = String(opts.text ?? '')
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, ALERT_TEXT_MAX);
if (!text) throw httpError(400, 'text is required.');
let duration = opts.duration === undefined || opts.duration === null
? ALERT_DURATION_DEFAULT_S
: Number(opts.duration);
if (!Number.isFinite(duration) || duration < 0) {
throw httpError(400, `duration is seconds, 1 to ${ALERT_DURATION_MAX_S}, or 0 to hold the alert until it is cleared.`);
}
if (duration > 0) duration = Math.min(ALERT_DURATION_MAX_S, Math.max(1, duration));
const src = ALERT_SOURCE_RE.test(String(source || '')) ? String(source) : 'api';
const previous = activeAlerts.get(channel);
if (previous) clearTimeout(previous.timer);
const now = Date.now();
const alert = {
id: 'a' + (++alertSeq),
channel,
level,
text,
source: src,
startedAt: now,
duration,
endsAt: duration > 0 ? now + Math.round(duration * 1000) : null,
timer: null,
};
if (alert.endsAt) {
alert.timer = setTimeout(() => {
if (activeAlerts.get(channel) === alert) clearAlert(channel);
}, alert.endsAt - now);
alert.timer.unref?.();
}
activeAlerts.set(channel, alert);
broadcastShellEvent('alert', alertView(alert));
console.log(`[proddash] alert: ${level} on channel ${channel} from ${src} — "${text}"`
+ (alert.endsAt ? ` (${duration} s)` : ' (until cleared)'));
return alertView(alert);
}
/** End the alert on a channel — early, or because its time is up. True when there was one. */
function clearAlert(channel) {
const alert = activeAlerts.get(Number(channel));
if (!alert) return false;
clearTimeout(alert.timer);
activeAlerts.delete(alert.channel);
broadcastShellEvent('alert-clear', { channel: alert.channel, id: alert.id });
return true;
}
/** A module that stops takes its alerts with it — a held alert must never outlive what raised it. */
function clearAlertsFrom(source) {
for (const alert of [...activeAlerts.values()]) {
if (alert.source === source) clearAlert(alert.channel);
}
}
/**
* Persist the alert colours into this machine's proddash.json (every other
* key there is kept), remember them, and tell every open dashboard and
* admin page. Throws an httpError for an unreadable settings file.
*/
function setAlertColors(raw) {
const colors = normalizeAlertColors(raw);
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the alert colours.`);
}
}
const alerts = existing.alerts && typeof existing.alerts === 'object' && !Array.isArray(existing.alerts)
? existing.alerts
: {};
writeJson(file, { ...existing, alerts: { ...alerts, colors } });
alertColors = colors;
shellConfig.alerts = { ...(shellConfig.alerts || {}), colors };
broadcastShellEvent('alerts-config', { colors: normalizeAlertColors(colors) });
return normalizeAlertColors(colors);
}
/* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the
@@ -899,9 +1148,21 @@ async function fetchArchive() {
signal: AbortSignal.timeout(30000),
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`;
// A forge that wants a sign-in does not refuse: it redirects the download
// to its login page, which arrives as a 200 of HTML. Say that, not
// zlib's "incorrect header check".
if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) {
throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`);
}
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`);
const gz = Buffer.from(await res.arrayBuffer());
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) {
const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim();
const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8'));
throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`);
}
const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) {
@@ -1060,13 +1321,22 @@ async function installModule(id) {
if (download) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set();
for (const [rel, data] of remoteEntry.files) {
if (!safeRelPath(rel)) continue;
shipped.add(rel);
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data);
}
const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in
// by hand on a machine whose toolchain can't build it — are gitignored,
// so the repo never ships them. Replacing the folder must not destroy
// them: carry executables the download doesn't include over from the
// copy being replaced (or from the app's bundled copy on a first
// install), or "update" quietly kills a working module.
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
unmountModule(id);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
@@ -1083,6 +1353,43 @@ async function installModule(id) {
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
}
/**
* Copy executable files (any execute bit) that `shipped` does not include
* from the first existing source dir into `staging`, keeping their relative
* path and mode. Executables only, on purpose: they are the per-machine
* build artifacts (gitignored, so never in a download), while stray text
* files from an old version — code an update deliberately removed — must
* NOT be resurrected.
*/
function carryOverExecutables(sourceDirs, staging, shipped) {
const from = sourceDirs.find((d) => {
try { return fs.statSync(d).isDirectory(); } catch { return false; }
});
if (!from) return;
const walk = (rel) => {
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
walk(relPath);
continue;
}
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
try {
const stat = fs.statSync(path.join(from, relPath));
if (!(stat.mode & 0o111)) continue;
const target = path.join(staging, relPath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(from, relPath), target);
fs.chmodSync(target, stat.mode);
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
} catch (err) {
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
}
}
};
walk('');
}
/** Remove a module: an installed copy is deleted; a bundled one is switched
off and hidden (its folder belongs to the app). Settings are kept so a
later reinstall picks them up again. */
@@ -1178,9 +1485,18 @@ const DATA_DIR_INSIDE_ROOT = (() => {
return rel.split(path.sep).join('/') + '/';
})();
/* A packaged install — the macOS app, the Windows bundle: anything that is
not a git checkout, the same test the updater uses to choose the archive
over git pull — carries no modules of its own. Every module comes from
the catalog into the data directory, and the shell's own update never
brings one along. A checkout keeps running the modules in its tree, which
is what development wants. */
const PACKAGED = !fs.existsSync(path.join(ROOT, '.git'));
function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
if (PACKAGED && (rel === 'modules' || rel.startsWith('modules/'))) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
}
@@ -1523,6 +1839,29 @@ async function handleAdminApi(req, res, urlPath) {
return;
}
// Several at once — a fresh install picking its set. One result per id;
// a failure stops nothing else.
if (urlPath === '/api/admin/modules/install' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => String(id)))] : [];
if (!ids.length) return sendJson(res, 400, { error: 'Nothing to install.' });
const results = [];
for (const id of ids) {
try {
results.push({ ok: true, ...(await installModule(id)) });
} catch (err) {
results.push({ ok: false, id, error: err?.message || String(err) });
}
}
return sendJson(res, 200, { ok: results.every((r) => r.ok), results });
}
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
if (install && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
@@ -1587,9 +1926,91 @@ async function handleAdminApi(req, res, urlPath) {
}
}
/* — alert colours: { "colors": { "low": [a, b], "medium": [a, b], "critical": [a, b] } } — */
if (urlPath === '/api/admin/alerts' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, colors: setAlertColors(body.colors) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
/* — a test flash, so the colours can be judged on a real dashboard:
{ "level": "critical", "channel": 1 } — */
if (urlPath === '/api/admin/alerts/test' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
const level = String(body.level ?? '');
const alert = raiseAlert({
channel: body.channel,
level,
text: `Test — ${level} alert`,
duration: 8,
}, 'admin');
return sendJson(res, 200, { ok: true, alert });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
}
/* ── alerts API ─────────────────────────────────────────────────────── */
/* Open to every dashboard and to module servers over loopback, like a
module's own routes: an alert is a flash on screens in this building and
nothing more, so it is gated no harder than saving a layout. Requiring
application/json on the POST forces the CORS preflight a page on another
origin cannot pass. */
async function handleAlertsApi(req, res, urlPath) {
if (urlPath === '/api/alerts' && req.method === 'GET') {
return sendJson(res, 200, alertsView());
}
if (urlPath === '/api/alerts' && req.method === 'POST') {
const contentType = String(req.headers['content-type'] || '').toLowerCase();
if (!contentType.startsWith('application/json')) {
return sendJson(res, 415, { error: 'Send application/json.' });
}
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, alert: raiseAlert(body, body.source) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
const m = urlPath.match(/^\/api\/alerts\/(\d{1,2})$/);
if (m && req.method === 'DELETE') {
const channel = Number(m[1]);
if (channel < 1 || channel > ALERT_CHANNELS) {
return sendJson(res, 400, { error: `channel must be a whole number from 1 to ${ALERT_CHANNELS}.` });
}
return sendJson(res, 200, { ok: true, cleared: clearAlert(channel) });
}
sendJson(res, 404, { error: 'Unknown alerts endpoint.' });
}
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
/** Names double as file names — keep them boring on purpose. */
@@ -1648,6 +2069,9 @@ function sanitizeLayout(raw) {
// The grid density the layout was arranged on, when it says and it is one
// the shell knows; a browser loading it cuts its board the same way.
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
// Which alert channels the dashboard listens to — which screen flashes
// for what is part of how a booth is arranged, so it rides along too.
...(Array.isArray(raw?.alertChannels) ? { alertChannels: sanitizeAlertChannels(raw.alertChannels) } : {}),
tiles: tiles.map((t) => {
const tile = {
id: String(t?.id || ''),
@@ -1912,8 +2336,17 @@ function handleRequest(req, res) {
/* — shell API — */
if (urlPath === '/api/modules' && req.method === 'GET') {
// Beside what dashboards may load: the installed modules that are not
// running, by id and name only, so a tile one of them left in a layout
// can say why it is not drawn ("module disabled") instead of guessing at
// "not installed". Nothing of their config travels with it.
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
id,
name: String(manifests.get(id).name || id),
reason: isEnabled(id) ? 'incompatible' : 'disabled',
}));
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
.then((list) => sendJson(res, 200, { modules: list }))
.then((list) => sendJson(res, 200, { modules: list, inactive }))
.catch((err) => {
console.error('[proddash] /api/modules failed:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
@@ -1946,6 +2379,16 @@ function handleRequest(req, res) {
return;
}
/* — alerts: what is flashing, raising one, ending one — */
if (urlPath === '/api/alerts' || urlPath.startsWith('/api/alerts/')) {
handleAlertsApi(req, res, urlPath).catch((err) => {
console.error('[proddash] alerts API error:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
else try { res.end(); } catch { /* gone */ }
});
return;
}
/* — named layouts — */
if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) {
handleLayoutsApi(req, res, urlPath).catch((err) => {
@@ -2017,6 +2460,7 @@ server.requestTimeout = 0;
/* ── boot ───────────────────────────────────────────────────────────── */
unbundleModules();
discoverModules();
mountAllModules();
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
'use strict';
/**
* A pretend AbleSet for testing the ableset module without the booth rig.
* Speaks AbleSet's OSC API over UDP: answers /subscribe, /getValues and
* /unsubscribe, sends /heartbeat every 2 s and pushes value changes while a
* setlist plays through — sections, a tempo per song, a section that loops
* twice, a song queued out of order, and the time left in song and set.
*
* node tools/ableset-mock.js [port] (default 39051)
* --stopped start with the transport stopped (press nothing; it stays)
* --no-live report Ableton Live as not connected
* --speed <x> play x times faster than real time (default 1)
*
* Point the AbleSet module at host 127.0.0.1 and this port.
*/
const dgram = require('dgram');
const { encodeMessage, decodePacket } = require('../modules/ableset/osc');
const argv = process.argv.slice(2);
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 39051;
const SPEED = Number(argv[argv.indexOf('--speed') + 1]) > 0 && argv.includes('--speed') ? Number(argv[argv.indexOf('--speed') + 1]) : 1;
let playing = !argv.includes('--stopped');
const live = !argv.includes('--no-live');
// name, tempo, [section name, bars] …
const SONGS = [
['1- CALL TO WORSHIP', 72, [['INTRO', 4], ['READING', 12], ['OUTRO', 4]]],
['2 -ALLELUIA - A', 125, [['INTRO', 8], ['CHORUS', 8], ['VERSE 1', 16], ['PRE-CHORUS', 8], ['CHORUS', 16], ['BRIDGE', 16], ['CHORUS', 16], ['ENDING', 8]]],
['3- WHAT A GOD - A', 76.81, [['INTRO', 4], ['VERSE 1', 16], ['CHORUS', 8], ['VERSE 2', 16], ['CHORUS', 8], ['BRIDGE', 8], ['BRIDGE', 8], ['CHORUS', 16], ['TAG', 4], ['ENDING', 8]]],
['4- The Blood - Gb', 68, [['INTRO', 4], ['VERSE 1', 8], ['CHORUS', 8], ['VERSE 2', 8], ['CHORUS', 8], ['OUTRO', 4]]],
['LOOP', 100, [['LOOP', 16]]],
['5- BE PRAISED- Db', 140, [['INTRO', 8], ['VERSE', 16], ['CHORUS', 16], ['INSTRUMENTAL', 8], ['CHORUS', 16], ['ENDING', 4]], '6/8'],
];
const COLORS = ['default', 'blue', 'default', 'red', 'default', 'purple'];
// Lay the songs end to end on one arrangement, as AbleSet does.
let cursor = 8;
const songs = SONGS.map(([name, tempo, parts, sig], i) => {
const [n, d] = (sig || '4/4').split('/').map(Number);
const bpb = (n * 4) / d;
const start = cursor;
const sections = parts.map(([sname, bars]) => {
const s = { name: sname, start: cursor, end: cursor + bars * bpb };
cursor = s.end;
return s;
});
return { name, tempo, sig: [n, d], start, end: cursor, sections, color: COLORS[i] || 'default' };
});
const secs = (song, beats) => (beats * 60) / song.tempo;
const songDuration = (song) => secs(song, song.end - song.start);
let songIdx = 1;
let beats = songs[songIdx].start + 40; // a little way into the second song
let loopsLeft = 0; // section-loop repeats still to play
let queued = null; // index of a song queued out of order
const subscribers = new Map(); // "ip:port" → { ip, port }
const sock = dgram.createSocket('udp4');
const sent = new Map();
function values() {
const song = songs[songIdx];
const sIdx = Math.max(0, song.sections.findIndex((s) => beats >= s.start && beats < s.end));
const sect = song.sections[sIdx];
const nextSect = song.sections[sIdx + 1] || null;
const nextSongIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : -1;
const nextSong = songs[nextSongIdx] || null;
const leftInSong = secs(song, song.end - beats);
const leftInSet = leftInSong + songs.slice(songIdx + 1).reduce((a, s) => a + songDuration(s), 0);
const col = (name) => [name, name === 'default' ? '#FFFFFF' : { blue: '#3B82F6', red: '#EF4444', purple: '#A855F7' }[name] || '#FFFFFF'];
return {
'/global/isConnected': [live ? 1 : 0],
'/global/isLoadingFile': [0],
'/global/isPlaying': [playing ? 1 : 0],
'/global/isRecording': [0],
'/global/tempo': [song.tempo],
'/global/timeSignature': song.sig,
'/global/beatsPosition': [Math.floor(beats)],
'/global/finePosition': [beats],
'/setlist/name': ['260927 AB (mock)'],
'/setlist/songs': songs.map((s) => s.name),
'/setlist/songDurations': songs.map(songDuration),
'/setlist/songColors': songs.map((s) => s.color),
'/setlist/sections': song.sections.map((s) => s.name),
'/setlist/sectionColors': song.sections.map((s) => (s.name === 'BRIDGE' ? 'purple' : 'default')),
'/setlist/activeSongName': [song.name],
'/setlist/activeSongIndex': [songIdx],
'/setlist/activeSongStart': [song.start],
'/setlist/activeSongEnd': [song.end],
'/setlist/activeSongDuration': [songDuration(song)],
'/setlist/activeSongColor': col(song.color),
'/setlist/activeSectionName': [sect.name],
'/setlist/activeSectionIndex': [sIdx],
'/setlist/activeSectionStart': [sect.start],
'/setlist/activeSectionEnd': [sect.end],
'/setlist/activeSectionDuration': [secs(song, sect.end - sect.start)],
'/setlist/activeSectionColor': col(sect.name === 'BRIDGE' ? 'purple' : 'default'),
'/setlist/activeSectionLoopMode': [loopsLeft > 0 ? 'loop' : null],
'/setlist/activeSectionLoopCount': [loopsLeft],
'/setlist/isInActiveSectionLoop': [loopsLeft > 0 ? 1 : 0],
'/setlist/nextSectionName': [nextSect ? nextSect.name : null],
'/setlist/nextSectionIndex': [nextSect ? sIdx + 1 : -1],
'/setlist/nextSectionStart': [nextSect ? nextSect.start : null],
'/setlist/nextSectionEnd': [nextSect ? nextSect.end : null],
'/setlist/nextSongName': [nextSong ? nextSong.name : null],
'/setlist/nextSongIndex': [nextSongIdx],
'/setlist/nextSongDuration': [nextSong ? songDuration(nextSong) : null],
'/setlist/nextSongColor': col(nextSong ? nextSong.color : 'default'),
'/setlist/queuedName': [queued != null ? songs[queued].name : null, null],
'/setlist/queuedIndex': [queued != null ? queued : -1, -1],
'/setlist/queuedType': [queued != null ? 'song' : null],
'/setlist/loopEnabled': [0],
'/setlist/isCountingIn': [0],
'/setlist/remainingTimeInSong': [Math.round(leftInSong)],
'/setlist/remainingTimeInSet': [Math.round(leftInSet)],
'/audioInterfaces/connected': [1],
'/audioInterfaces/count': [1],
'/audioInterfaces/names': ['PlayAUDIO12'],
'/audioInterfaces/status': ['sceneA'],
'/version/app': ['mock'],
};
}
function sendTo(sub, address, args) {
sock.send(encodeMessage(address, args), sub.port, sub.ip); // null goes out as nil, as AbleSet sends it
}
function push(force = false) {
const now = values();
for (const [address, args] of Object.entries(now)) {
const key = JSON.stringify(args);
if (!force && sent.get(address) === key && address !== '/global/finePosition') continue;
if (address === '/global/finePosition' && !playing && !force) continue;
sent.set(address, key);
for (const sub of subscribers.values()) sendTo(sub, address, args);
}
}
sock.on('message', (buf, rinfo) => {
let msgs;
try { msgs = decodePacket(buf); } catch { return; }
for (const { address, args } of msgs) {
const ip = args[0] === 'auto' || !args[0] ? rinfo.address : String(args[0]);
const port = Number(args[1]) || rinfo.port;
if (address === '/subscribe') {
subscribers.set(`${ip}:${port}`, { ip, port });
sendTo({ ip, port }, '/subscribed', []);
console.log(`subscribed ${ip}:${port} (${args[2] || 'no name'})`);
} else if (address === '/unsubscribe') {
subscribers.delete(`${ip}:${port}`);
console.log(`unsubscribed ${ip}:${port}`);
} else if (address === '/getValues') {
const sub = { ip, port };
for (const [a, v] of Object.entries(values())) sendTo(sub, a, v);
}
}
});
setInterval(() => {
for (const sub of subscribers.values()) sendTo(sub, '/heartbeat', []);
}, 2000);
let last = Date.now();
setInterval(() => {
const now = Date.now();
const dtSec = ((now - last) / 1000) * SPEED;
last = now;
if (playing) {
const song = songs[songIdx];
const before = song.sections.find((s) => beats >= s.start && beats < s.end);
beats += dtSec * (song.tempo / 60);
// The second BRIDGE of song 3 loops twice before it lets go.
if (before && beats >= before.end && loopsLeft > 0) {
loopsLeft -= 1;
beats = before.start + (beats - before.end);
}
const after = song.sections.find((s) => beats >= s.start && beats < s.end);
if (after && after !== before && after.name === 'BRIDGE' && song.sections.indexOf(after) === 6) loopsLeft = 2;
if (beats >= song.end) {
// Stop at the end of each song, as a service does, then carry on.
const nextIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : 0;
queued = null;
songIdx = nextIdx;
beats = songs[songIdx].start;
playing = false;
push();
setTimeout(() => { playing = true; last = Date.now(); push(); }, 4000 / SPEED);
// Halfway through the set someone queues the last song early.
if (songIdx === 3) setTimeout(() => { queued = 5; push(); }, 8000 / SPEED);
}
}
push();
}, 100);
sock.bind(PORT, () => console.log(`AbleSet mock on udp ${PORT}${playing ? '' : ' (stopped)'}${live ? '' : ' (Live not connected)'}${SPEED !== 1 ? ` at ${SPEED}×` : ''}`));
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
'use strict';
/**
* Raise, clear or list ProdDash alerts from the command line — for trying a
* dashboard's channels and the admin colours, or from a script on the
* machine running ProdDash (anything that can POST JSON can raise one).
*
* node tools/alert.js "Service over by 5:00" # low, channel 1, 15 s
* node tools/alert.js -c 2 -l critical -d 0 "LTC lost" # held until cleared
* node tools/alert.js -c 2 --clear
* node tools/alert.js --list
*
* Options: -c/--channel 1–6 (default 1), -l/--level low|medium|critical
* (default low), -d/--duration seconds, 0 to hold until cleared (default the
* server's 15), --url http://host:port (default http://127.0.0.1:24500, or
* $PRODDASH_URL), --clear, --list. Node 18+, no dependencies.
*/
const args = process.argv.slice(2);
const opts = { channel: 1, level: 'low', duration: undefined, url: process.env.PRODDASH_URL || 'http://127.0.0.1:24500', clear: false, list: false };
const words = [];
for (let i = 0; i < args.length; i += 1) {
const a = args[i];
const next = () => args[++i];
if (a === '-c' || a === '--channel') opts.channel = Number(next());
else if (a === '-l' || a === '--level') opts.level = String(next() || '');
else if (a === '-d' || a === '--duration') opts.duration = Number(next());
else if (a === '--url') opts.url = String(next() || opts.url);
else if (a === '--clear') opts.clear = true;
else if (a === '--list') opts.list = true;
else if (a === '-h' || a === '--help') {
console.log('usage: node tools/alert.js [-c 1-6] [-l low|medium|critical] [-d seconds] [--url http://host:port] "text"\n'
+ ' node tools/alert.js -c <n> --clear\n'
+ ' node tools/alert.js --list');
process.exit(0);
} else words.push(a);
}
const base = opts.url.replace(/\/+$/, '');
async function call(path, init) {
const res = await fetch(base + path, init);
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
return body;
}
(async () => {
if (opts.list) {
const body = await call('/api/alerts');
const active = body.active || [];
console.log(`${active.length} active alert${active.length === 1 ? '' : 's'}; colours:`,
Object.entries(body.colors || {}).map(([k, v]) => `${k} ${v.join('→')}`).join(', '));
for (const a of active) {
console.log(` channel ${a.channel} ${a.level.padEnd(8)} "${a.text}" from ${a.source}`
+ (a.remainingMs === null ? ' (until cleared)' : ` (${Math.ceil(a.remainingMs / 1000)} s left)`));
}
return;
}
if (opts.clear) {
const body = await call(`/api/alerts/${opts.channel}`, { method: 'DELETE' });
console.log(body.cleared ? `cleared channel ${opts.channel}` : `nothing was on channel ${opts.channel}`);
return;
}
const text = words.join(' ').trim();
if (!text) {
console.error('say what happened: node tools/alert.js -c 1 -l critical "LTC lost"');
process.exit(2);
}
const payload = { channel: opts.channel, level: opts.level, text, source: 'cli' };
if (opts.duration !== undefined) payload.duration = opts.duration;
const body = await call('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const a = body.alert;
console.log(`raised ${a.level} on channel ${a.channel}: "${a.text}"`
+ (a.remainingMs === null ? ' (until cleared)' : ` (${a.duration} s)`));
})().catch((err) => {
console.error(err.message || String(err));
process.exit(1);
});
+277
View File
@@ -0,0 +1,277 @@
'use strict';
/**
* Drives the shell's alerts through its HTTP API and shell stream: the
* defaults, every refusal, raise → `alert` frame → timeout → `alert-clear`
* frame, replacement on a channel, an early clear, text tidying, the
* channel list riding along in a saved layout, the admin colour save (and
* its `alerts-config` frame, and what lands in proddash.json), and the admin
* test flash.
*
* node tools/alerts.test.js
*
* Plain Node, no dependencies. Starts server.js on a spare port with a
* temporary data directory, talks to it over HTTP, exits non-zero on the
* first failure and always stops the server it started.
*/
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
/** A bare SSE reader for /api/events: collects { event, data } frames and waits for one. */
function openEvents(url) {
const frames = [];
const waiters = [];
const req = http.get(url, { headers: { accept: 'text/event-stream' } }, (res) => {
let buf = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
let event = 'message';
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
if (!data.length) continue;
let parsed = null;
try { parsed = JSON.parse(data.join('\n')); } catch { parsed = data.join('\n'); }
const frame = { event, data: parsed };
frames.push(frame);
for (const w of [...waiters]) {
if (w.match(frame)) {
waiters.splice(waiters.indexOf(w), 1);
w.resolve(frame);
}
}
}
});
});
return {
frames,
/** The next frame (already seen or still to come) that `match` accepts. */
next(match, ms = 5000, label = 'frame') {
const seen = frames.find(match);
if (seen) {
frames.splice(frames.indexOf(seen), 1);
return Promise.resolve(seen);
}
return new Promise((resolve, reject) => {
const w = { match, resolve: (f) => { clearTimeout(timer); frames.splice(frames.indexOf(f), 1); resolve(f); } };
const timer = setTimeout(() => {
waiters.splice(waiters.indexOf(w), 1);
reject(new Error(`no ${label} within ${ms} ms`));
}, ms);
waiters.push(w);
});
},
close() { req.destroy(); },
};
}
(async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-alerts-test-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const raise = (alert) => api('/api/alerts', { method: 'POST', body: JSON.stringify(alert) });
let events = null;
let failed = false;
try {
await waitFor(base + '/api/alerts');
events = openEvents(base + '/api/events');
// ── defaults ──
let r = await api('/api/alerts');
assert.equal(r.status, 200);
assert.equal(r.body.channels, 6, 'six channels');
assert.deepEqual(r.body.levels, ['low', 'medium', 'critical']);
assert.deepEqual(Object.keys(r.body.colors), ['low', 'medium', 'critical'], 'a pair per level');
for (const pair of Object.values(r.body.colors)) {
assert.equal(pair.length, 2);
for (const c of pair) assert.match(c, /^#[0-9a-f]{6}$/, 'colours are #rrggbb');
}
assert.deepEqual(r.body.active, [], 'nothing active on a fresh server');
const defaults = r.body.colors;
// ── refusals say why ──
r = await raise({ channel: 7, level: 'low', text: 'x' });
assert.equal(r.status, 400); assert.match(r.body.error, /channel/);
r = await raise({ channel: 1, level: 'high', text: 'x' });
assert.equal(r.status, 400); assert.match(r.body.error, /level/);
r = await raise({ channel: 1, level: 'low', text: ' ' });
assert.equal(r.status, 400); assert.match(r.body.error, /text/);
r = await raise({ channel: 1, level: 'low', text: 'x', duration: -1 });
assert.equal(r.status, 400); assert.match(r.body.error, /duration/);
r = await raise({ channel: 1, level: 'low', text: 'x', duration: 'soon' });
assert.equal(r.status, 400); assert.match(r.body.error, /duration/);
{
const plain = await fetch(base + '/api/alerts', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: '{}' });
assert.equal(plain.status, 415, 'a POST that is not application/json is refused');
}
r = await fetch(base + '/api/alerts/9', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.status, 400, 'clearing a channel that does not exist is refused');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, [], 'a refused alert is not half-raised');
// ── raise → frame → timeout → clear frame ──
r = await raise({ channel: 2, level: 'MEDIUM', text: 'Service over by 5:00', duration: 1, source: 'pco-plan' });
assert.equal(r.status, 200, 'raise: ' + JSON.stringify(r.body));
const a = r.body.alert;
assert.equal(a.channel, 2);
assert.equal(a.level, 'medium', 'level is case-insensitive');
assert.equal(a.text, 'Service over by 5:00');
assert.equal(a.source, 'pco-plan', 'the source is kept');
assert.equal(a.duration, 1);
assert.ok(a.remainingMs > 0 && a.remainingMs <= 1000, 'remainingMs counts down from the duration');
let frame = await events.next((f) => f.event === 'alert' && f.data.id === a.id, 5000, '`alert` frame');
assert.equal(frame.data.text, a.text, 'the frame carries the alert');
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1, 'it is active');
assert.equal(r.body.active[0].id, a.id);
frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 2, 5000, '`alert-clear` frame at timeout');
assert.equal(frame.data.id, a.id, 'the clear names the alert that ended');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, [], 'gone once its time is up');
// ── defaults for duration; a held alert; replacement; early clear ──
r = await raise({ channel: 3, level: 'low', text: 'first' });
assert.equal(r.body.alert.duration, 15, '15 s when omitted');
r = await raise({ channel: 3, level: 'low', text: 'LTC lost', duration: 0 });
assert.equal(r.status, 200);
assert.equal(r.body.alert.endsAt, null, 'duration 0 holds the alert');
assert.equal(r.body.alert.remainingMs, null);
const held = r.body.alert;
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1, 'one alert per channel — the second replaced the first');
assert.equal(r.body.active[0].id, held.id);
r = await raise({ channel: 3, level: 'critical', text: 'replaced', duration: 600 });
const replaced = r.body.alert;
assert.equal(replaced.duration, 600, 'the cap is 600 s');
r = await raise({ channel: 3, level: 'critical', text: 'capped', duration: 9999 });
assert.equal(r.body.alert.duration, 600, 'longer is clamped to the cap');
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1);
assert.equal(r.body.active[0].text, 'capped');
r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.status, 200);
assert.equal(r.body.cleared, true, 'an early clear says it cleared something');
frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 3, 5000, '`alert-clear` frame after DELETE');
r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.body.cleared, false, 'clearing an empty channel is honest about it');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, []);
// ── text is tidied: control characters out, whitespace collapsed, 80 chars at most ──
r = await raise({ channel: 4, level: 'low', text: ' SPL\n\t 102 dB\u0007 — over ' + 'x'.repeat(100), duration: 60 });
assert.equal(r.status, 200);
assert.equal(r.body.alert.text.length, 80, 'cut at 80');
assert.ok(r.body.alert.text.startsWith('SPL 102 dB — over x'), 'whitespace collapsed, control characters gone');
assert.equal(r.body.alert.source, 'api', 'no source given → api');
r = await raise({ channel: 4, level: 'low', text: 'bad source', duration: 60, source: 'not a module id!' });
assert.equal(r.body.alert.source, 'api', 'a malformed source → api');
await fetch(base + '/api/alerts/4', { method: 'DELETE' });
// ── the channel list rides along in a saved layout ──
const tile = { id: 't1', module: 'clock', x: 0, y: 0, w: 2, h: 1, settings: {} };
r = await api('/api/layouts/Stage', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile], alertChannels: [3, 1, '2', 1, 9, 'x'] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
r = await api('/api/layouts/Stage');
assert.deepEqual(r.body.layout.alertChannels, [1, 2, 3], 'channels come back whole, deduplicated, sorted, 1–6 only');
r = await api('/api/layouts/Plain', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile] } }) });
r = await api('/api/layouts/Plain');
assert.equal('alertChannels' in r.body.layout, false, 'a layout saved without the list does not grow one');
// ── admin: colours (admin is open here — a fresh data directory has no PIN) ──
r = await api('/api/admin/alerts', {
method: 'POST',
body: JSON.stringify({ colors: { low: ['#112233', 'nonsense'], critical: ['#ABCDEF', '#000000'], bogus: ['#111111', '#222222'] } }),
});
assert.equal(r.status, 200, 'colour save: ' + JSON.stringify(r.body));
assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'a malformed colour takes the default');
assert.deepEqual(r.body.colors.medium, defaults.medium, 'a level left out keeps the default');
assert.deepEqual(r.body.colors.critical, ['#abcdef', '#000000'], 'colours are lower-cased');
assert.equal('bogus' in r.body.colors, false, 'an unknown level is dropped');
frame = await events.next((f) => f.event === 'alerts-config', 5000, '`alerts-config` frame');
assert.deepEqual(frame.data.colors, r.body.colors, 'open dashboards hear the new colours');
r = await api('/api/alerts');
assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'GET reflects the save');
const settings = JSON.parse(fs.readFileSync(path.join(dataDir, 'proddash.json'), 'utf8'));
assert.deepEqual(settings.alerts.colors.critical, ['#abcdef', '#000000'], 'saved under alerts.colors in proddash.json');
// ── admin: the test flash ──
r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'critical', channel: 5 }) });
assert.equal(r.status, 200, 'test: ' + JSON.stringify(r.body));
assert.equal(r.body.alert.channel, 5);
assert.equal(r.body.alert.level, 'critical');
assert.equal(r.body.alert.source, 'admin');
assert.match(r.body.alert.text, /^Test — critical alert$/);
assert.equal(r.body.alert.duration, 8, 'a test flash is short');
r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'nope', channel: 5 }) });
assert.equal(r.status, 400, 'a bad test level is refused like any other');
await fetch(base + '/api/alerts/5', { method: 'DELETE' });
console.log('alerts: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
try { events?.close(); } catch { /* already closed */ }
child.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(dataDir, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();
+153
View File
@@ -8,9 +8,14 @@
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
* GET /services/v2/folders nested folders (parent rel)
* GET /services/v2/folders/{id}
* Run: node tools/pco-mock.js [port] [--types N] (--types pads the service
* type list out to N, for trying a picker against an account that has dozens.)
*
* GET /services/v2/service_types every service type, with its
* parent folder relationship
* GET /services/v2/service_types/{id}
* GET /services/v2/service_types/{id}/teams every team, including one
* nobody is scheduled on
* GET /services/v2/service_types/{id}/item_note_categories
* the note categories the
* items below use, plus one
@@ -25,6 +30,15 @@
* "Starts in" state is visible
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
* ItemNote compound documents
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
* records + Team / Person, with
* confirmed / unconfirmed /
* declined statuses, a team
* nobody is on, and people with
* and without a photo
* GET /avatars/{personId}.svg those photos (initials on a
* disc) — the module proxies
* them like the real CDN
*
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
* playlist, so running both mocks exercises the ProPresenter matching.
@@ -38,6 +52,10 @@
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
// --types N pads the list out to N with planless filler, for accounts that
// carry dozens of them: enough to see whether a picker still works at that size.
const TYPE_COUNT = Number.parseInt((process.argv.find((a) => a.startsWith('--types')) || '').split('=')[1]
|| (process.argv[process.argv.indexOf('--types') + 1] || ''), 10);
const bootedAt = Date.now();
/* ── data ───────────────────────────────────────────────────────────── */
@@ -57,6 +75,18 @@ const SERVICE_TYPES = [
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
];
if (Number.isFinite(TYPE_COUNT) && TYPE_COUNT > SERVICE_TYPES.length) {
FOLDERS.push({ id: '300', name: 'Other Campuses', parentId: '' });
for (let i = SERVICE_TYPES.length; i < TYPE_COUNT; i += 1) {
SERVICE_TYPES.push({
id: String(2000 + i),
name: `Campus ${String.fromCharCode(65 + (i % 26))}${i >= 26 + 5 ? Math.floor(i / 26) : ''} Service`,
frequency: 'Every week',
parentId: i % 3 === 0 ? '300' : (i % 3 === 1 ? '110' : ''),
});
}
}
function iso(ms) {
return new Date(ms).toISOString();
}
@@ -194,6 +224,107 @@ function itemsDocument(planId, include) {
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
/**
* Who is scheduled, as Planning Center returns it from
* /plans/{id}/team_members?include=team,person: teams in their own order,
* people with a position and a scheduling status ("C" confirmed,
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
* has no picture for — the tile draws their initials instead.
*/
const TEAMS = [
{
id: 't1', name: 'Worship', sequence: 1, members: [
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
],
},
{
id: 't2', name: 'Production', sequence: 2, members: [
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
],
},
{
id: 't3', name: 'Hosting', sequence: 3, members: [
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
],
},
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
];
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
function avatarSvg(name) {
const words = String(name).trim().split(/\s+/);
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
let hash = 0;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
}
function teamMembersDocument(planId, include, port) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = [];
const included = [];
const seenTeams = new Set();
for (const team of TEAMS) {
for (const m of team.members) {
data.push({
type: 'PlanPerson',
id: m.id,
attributes: {
name: m.name,
status: m.status,
team_position_name: m.position,
notes: m.notes || null,
decline_reason: m.decline || null,
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
status_updated_at: iso(bootedAt - 3600000),
},
relationships: {
plan: { data: { type: 'Plan', id: planId } },
team: { data: { type: 'Team', id: team.id } },
person: { data: { type: 'Person', id: m.person } },
},
});
if (wants.has('person')) {
included.push({
type: 'Person',
id: m.person,
attributes: {
name: m.name,
first_name: m.name.split(' ')[0],
last_name: m.name.split(' ').slice(-1)[0],
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
},
});
}
if (wants.has('team') && !seenTeams.has(team.id)) {
seenTeams.add(team.id);
included.push({
type: 'Team',
id: team.id,
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
});
}
}
}
return { data, included };
}
function folderRecord(f) {
return {
type: 'Folder',
@@ -242,6 +373,16 @@ const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
// Photos are plain images on Planning Center's CDN — no auth there, none here.
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
if (avatar) {
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
if (!person) return notFound(res);
const body = avatarSvg(person.name);
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
return void res.end(body);
}
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
}
@@ -266,6 +407,13 @@ const server = http.createServer((req, res) => {
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
if (!st) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
if (parts[2] === 'teams' && parts.length === 3) {
return collection(res, TEAMS.map((t) => ({
type: 'Team', id: t.id,
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
})), url);
}
if (parts[2] === 'item_note_categories' && parts.length === 3) {
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
}
@@ -286,6 +434,11 @@ const server = http.createServer((req, res) => {
if (!plan) return notFound(res);
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
if (parts[4] === 'team_members') {
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
doc.links = { self: url.pathname + url.search };
return send(res, 200, doc);
}
if (parts[4] === 'items') {
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
doc.links = { self: url.pathname + url.search };
+29
View File
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
sendJson(res, { error: 'not found' }, 404);
});
/* ── transport (video countdown): a 4-minute cycle ─────────────────
0–60 s "Sermon Bumper.mp4" (3:00) playing
60–75 s the same bumper paused at 1:00
75–90 s playing again
90–120 s nothing loaded
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
must infer looping from the position jumping back.
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
name, artist, audio_only, duration }, `time` = bare seconds (float). */
function transport() {
const phase = ((Date.now() - startedAt) / 1000) % 240;
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
if (phase < 90) {
const playing = phase < 60 || phase >= 75;
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
return {
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
time: Math.round(pos * 10) / 10,
};
}
if (phase < 120) return none;
return {
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
time: Math.round(((phase - 120) % 15) * 10) / 10,
};
}
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto');
+70 -9
View File
@@ -1,9 +1,15 @@
/**
* Mock ProdCom API for verifying the prodcom-transcript module without a
* live ProdCom. Serves the endpoints the module uses — channels, groups,
* transcript history and the SSE stream — and simulates live speech:
* entries appear in progress, grow word by word (same id, in-place update),
* then complete.
* transcript history, the SSE stream and POST /api/v1/channels/{id}/send —
* and simulates live speech: entries appear in progress, grow word by word
* (same id, in-place update), then complete. Text sent to a channel lands
* in that channel's history as a completed entry with source "typed" and
* goes out on the stream, which is how a send from the Send Message module
* can be watched arriving in the transcript tile. A text containing "!429"
* answers RATE_LIMITED; an unknown channel NOT_FOUND. With PRODCOM_MOCK_KEY
* set, every request must carry it as a Bearer token (UNAUTHORIZED
* otherwise) — the module's "API key" field.
*
* node tools/prodcom-mock.js [port] (default 24480)
*
@@ -17,12 +23,13 @@ const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24480', 10);
const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10);
const API_KEY = String(process.env.PRODCOM_MOCK_KEY || '');
const CHANNELS = [
{ id: 'ch-stage', name: 'Stage', color: '#e0645c' },
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0' },
{ id: 'ch-video', name: 'Video', color: '#5cd08c' },
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c' },
{ id: 'ch-stage', name: 'Stage', color: '#e0645c', sourceType: 'localAudio', typingEnabled: true, isSelected: true, unreadCount: 0 },
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0', sourceType: 'localAudio', typingEnabled: true, isSelected: false, unreadCount: 0 },
{ id: 'ch-video', name: 'Video', color: '#5cd08c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 },
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 },
];
const GROUPS = [
@@ -69,6 +76,7 @@ function stepSimulation() {
text: words[0],
date: new Date().toISOString(),
inProgress: true,
source: 'audio',
};
speaking = { entry, words, at: 1 };
history.push(entry);
@@ -90,15 +98,68 @@ function stepSimulation() {
setInterval(stepSimulation, STEP_MS);
function sendJson(res, body) {
res.writeHead(200, { 'Content-Type': 'application/json' });
function sendJson(res, body, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
/** ProdCom's error envelope. */
function sendError(res, status, code, message) {
sendJson(res, { error: { code, message }, meta: { timestamp: new Date().toISOString() } }, status);
}
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); }
});
req.on('error', () => resolve(null));
});
}
/** Typed text onto a channel: a completed entry with source "typed", like ProdCom makes. */
function sendText(channel, text) {
const entry = {
id: 'e' + RUN + '-' + nextId++,
channelId: channel.id,
channelName: channel.name,
text,
date: new Date().toISOString(),
inProgress: false,
source: 'typed',
hasBeenSeen: false,
};
history.push(entry);
if (history.length > 200) history.shift();
broadcast(entry);
console.log(`[prodcom-mock] typed → ${channel.name}: ${text}`);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://x.invalid`);
const p = url.pathname;
if (API_KEY && req.headers.authorization !== `Bearer ${API_KEY}`) {
return sendError(res, 401, 'UNAUTHORIZED', 'Authentication required or invalid API key');
}
// POST /api/v1/channels/{id or name}/send { text }
const send = /^\/api\/v1\/channels\/([^/]+)\/send$/.exec(p);
if (send && req.method === 'POST') {
const ref = decodeURIComponent(send[1]);
const channel = CHANNELS.find((c) => c.id === ref) || CHANNELS.find((c) => c.name.toLowerCase() === ref.toLowerCase());
return void readBody(req).then((body) => {
if (!channel) return sendError(res, 404, 'NOT_FOUND', 'Channel not found');
const text = typeof body?.text === 'string' ? body.text.trim() : '';
if (!body || !text) return sendError(res, 400, 'VALIDATION_ERROR', 'text is required');
if (text.includes('!429')) return sendError(res, 429, 'RATE_LIMITED', 'Slow down');
sendText(channel, text);
sendJson(res, { data: { sent: true }, meta: { timestamp: new Date().toISOString() } });
});
}
if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS });
if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS });
if (p === '/api/v1/transcript') {
+203
View File
@@ -0,0 +1,203 @@
'use strict';
/**
* A packaged ProdDash carries no modules. This drives a packaged copy of the
* shell — server.js and its client in a folder with no .git — through the
* three things that make that true:
*
* 1. an install made before 1.10.0 still has modules in its app folder:
* at boot they move into the data directory once, executables keeping
* their mode, an uninstalled one is dropped, and the app folder's
* modules/ is gone;
* 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing
* several at once lands them in the data directory and mounts them.
*
* node tools/unbundled.test.js
*
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
* directory. Exits non-zero on the first failure and cleans up after itself.
*/
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const REPO = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
function writeModule(dir, id, version, extra = {}) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
}
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
function buildArchive(dir) {
const top = path.join(dir, 'ProdDash-main');
fs.mkdirSync(top, { recursive: true });
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz');
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
return fs.readFileSync(out);
}
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
function buildPackagedApp(app) {
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
}
(async () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
const app = path.join(work, 'app');
const dataDir = path.join(work, 'data');
fs.mkdirSync(dataDir);
buildPackagedApp(app);
// The operator had uninstalled "gone" before this update.
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
const archive = buildArchive(path.join(work, 'forge'));
const forge = http.createServer((req, res) => {
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
res.writeHead(200, { 'Content-Type': 'application/gzip' });
return res.end(archive);
}
res.writeHead(404);
res.end();
});
const forgePort = await freePort();
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const env = {
...process.env,
PRODDASH_DATA_DIR: dataDir,
PORT: String(port),
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
PRODDASH_REPO: 'michael/ProdDash',
PRODDASH_NO_REMOTE_CHECK: '1',
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
};
let log = '';
let child = null;
const start = () => {
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
return child;
};
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
let failed = false;
try {
// ── 1. the move ──
start();
await waitFor(base + '/api/theme');
const moved = path.join(dataDir, 'modules', 'demo');
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
const helper = path.join(moved, 'bin', 'demo-helper');
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
assert.match(log, /the app folder carries no modules now/);
const state = (await api('/api/admin/state')).body;
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
// The catalog sees the repo's modules/ — as offers, not as a bundle.
let cat = (await api('/api/admin/catalog?refresh=1')).body;
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
// ── 2. a shell update carries no modules ──
const running = child;
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
log = '';
start();
await waitFor(base + '/api/theme');
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
cat = (await api('/api/admin/catalog')).body;
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
// ── 3. several at once ──
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
assert.equal(many.status, 200);
assert.equal(many.body.ok, false, 'one of them does not exist');
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
assert.equal(empty.status, 400, 'nothing to install is said');
console.log('unbundled shell: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2500));
} finally {
try { child?.kill('SIGTERM'); } catch { /* gone */ }
forge.close();
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(work, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();
+176
View File
@@ -0,0 +1,176 @@
'use strict';
/**
* The update check against a forge that misbehaves: the admin page must say
* what went wrong in words, never zlib's "incorrect header check".
*
* node tools/update-check.test.js
*
* Plain Node, no dependencies. Starts a mock forge whose answer to the
* branch-archive download can be switched at runtime, starts server.js on a
* spare port pointed at it, and reads the shell status the admin page shows
* after each kind of answer:
*
* - a Gitea that requires a sign-in redirects the download to its login
* page (a 200 of HTML) — the case that hit both booths on 2026-09-29;
* - a captive portal or a wrong address answers HTML outright;
* - a plain HTTP error;
* - and a real archive, which must still parse and offer the update.
*
* Exits non-zero on the first failure and always stops what it started.
*/
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
/** A branch archive the way a forge would build one: one top-level folder. */
function buildArchive(dir) {
const top = path.join(dir, 'ProdDash-main');
fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true });
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' }));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n');
const out = path.join(dir, 'main.tar.gz');
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
return fs.readFileSync(out);
}
(async () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-'));
const dataDir = path.join(work, 'data');
fs.mkdirSync(dataDir);
const archive = buildArchive(path.join(work, 'forge'));
// ── the mock forge ──
let mode = 'ok';
const LOGIN_PAGE = '<!DOCTYPE html>\n<html><head><title>Sign in</title></head><body>Sign in to continue</body></html>';
const forge = http.createServer((req, res) => {
const url = new URL(req.url, 'http://forge.invalid');
if (url.pathname === '/user/login') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(LOGIN_PAGE);
}
if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('not here');
}
switch (mode) {
case 'login-redirect':
res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) });
return res.end();
case 'html':
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end('<html><body>You must log in to this network first</body></html>');
case 'notfound':
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('no such branch');
default:
res.writeHead(200, { 'Content-Type': 'application/gzip' });
return res.end(archive);
}
});
const forgePort = await freePort();
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
const forgeBase = `http://127.0.0.1:${forgePort}`;
// ── ProdDash, pointed at it ──
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: {
...process.env,
PRODDASH_DATA_DIR: dataDir,
PORT: String(port),
PRODDASH_FORGE: forgeBase,
PRODDASH_REPO: 'michael/ProdDash',
PRODDASH_NO_REMOTE_CHECK: '1',
PRODDASH_LAUNCHER: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
/** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */
const check = async (m) => {
mode = m;
const r = await fetch(base + '/api/admin/catalog?refresh=1');
assert.equal(r.status, 200, `catalog: HTTP ${r.status}`);
const body = await r.json();
return body.shell;
};
let failed = false;
try {
await waitFor(base + '/api/theme');
let shell = await check('login-redirect');
assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error);
assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named');
assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words');
assert.equal(shell.updateAvailable, false);
shell = await check('html');
assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error);
shell = await check('notfound');
assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error);
shell = await check('ok');
assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error);
assert.equal(shell.remote.version, '9.9.9', 'and is read');
assert.equal(shell.updateAvailable, true, 'and offered');
// Back to a broken forge: the last good read is not mistaken for a current one.
shell = await check('login-redirect');
assert.match(shell.remote.error, /requires a sign-in/);
console.log('update check: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
child.kill('SIGTERM');
forge.close();
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(work, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();