Commit Graph
225 Commits
Author SHA1 Message Date
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
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 v1.10.0 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
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 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -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