Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 c73d0a3aac PCO Plan 1.6.0: the message document is matched by the plan's own name
The current item followed the live ProPresenter document by title alone,
and a sermon's document is named after the sermon — Waters names them
"YYMMDD Series Title - Part #" — never after its plan item. So the last
song stayed current through the whole message: North Attleboro on 20 Sept
spent 65 minutes on one song in two of its three services.

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:06:53 -04:00
michael 441cfe3882 Merge pull request 'Grid density: cut the board finer or coarser, per browser' (#14) from claude/grid-size into main 2026-09-22 11:45:51 -04:00
Waters ChurchandClaude Fable 5.1 7ca65a845f Grid Size: the stepper's row is a name and a number
The Layouts menu row reads "Grid Size  − 12 +": the label is the setting's
name, the readout is the bare column count, and the default/finer/coarser
caption under the label is gone. The buttons keep their tooltips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:35:26 -04:00
Waters ChurchandClaude Fable 5.1 e383070a9e Grid density: a coarser board settles its tiles instead of stacking them
Stepping the grid coarser pulled tiles past the right edge back in and
left them wherever that put them — on top of one another, which the
board never allows under a drag. Tiles now settle in reading order: one
that would land on a tile already settled takes the first free spot, the
same rule a newly added tile follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:26:05 -04:00
Waters ChurchandClaude Fable 5.1 2af7ef27ba Grid density: cut the board finer or coarser, per browser
Twelve columns fit a booth monitor and nothing else well: on a 4K wall a
three-wide tile is nearly a thousand pixels, and on a small screen four tiles
across is the most the grid allows. A stepper in the Layouts menu now cuts
the board into 8, 10, 12, 16, 20 or 24 columns. Row height follows the
column width, so cells keep their shape and a 3×2 tile is still a 3×2 tile,
only smaller — finer lets tiles be smaller so more fit, coarser suits a
screen read close up.

It is this browser's choice, not the server's: two displays at one location
can want different densities, so it saves with the layout — the local one
and a named one on the server — and a named layout comes back at the density
it was arranged on, which the load message says. An older layout with no
density is the default twelve.

Tiles keep their grid units when the density changes; the board simply gets
denser or coarser around them, and a tile now past the right edge is pulled
in. A module's minSize is enforced in cells as declared, so on a finer grid a
tile can be smaller than it could at twelve, which is the point; a new tile's
defaultSize is scaled to the density so it lands at about the size its author
meant. Leaving fullscreen goes back to the chosen density rather than the
stylesheet's twelve — that reset predates any notion of a chosen density.

Shell 1.9.0. The round-trip test carries gridCols and drops a value off the
scale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:19:49 -04:00
michael 5107b06f8c Merge pull request 'Band Lineup joins the repo — Christian's module, ready for every install' (#13) from claude/band-lineup-module into main 2026-09-22 09:44:18 -04:00
Waters ChurchandClaude Fable 5.1 5fb90190ae Band Lineup 1.11.0: into the catalog, and to the guide's rules
The module arrived in the previous commit exactly as it runs in North
Attleboro. This brings it to the interface rules every module follows and
puts it where the admin page can offer it.

The three hide-toggles — names, mic labels, parallax — sat in a folded
"Show / hide" group. Rule 8 puts four or fewer at the top of the gear with
no group at all, so they are ungrouped and first; the Birthday group keeps
its switch. The test-mode select loses its help line (rule 5). Everything
else in the gear was already within the rules, and every title-bar control
was already an icon with a tooltip.

The Planning Center mock moves out of the module folder to tools/, beside
the other upstream mocks, so an install does not download a development
tool with the module; README and CLAUDE.md say how to run it. The version
goes to 1.11.0 so the copy already installed on NA is offered the update
from the repo, and the module joins the README's list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:19:34 -04:00
christian 684f06abed Band Lineup module, as it runs in North Attleboro
The backstage stage board as a ProdDash tile: 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. The display half of the standalone Band
Lineup Electron app, ported to a module — imported here exactly as
installed on NA-Ingest (v1.10.1), so the repo is where it lives from now on.
2026-09-22 09:15:22 -04:00
michael 3435a0625e Merge pull request 'Layouts keep every tile they are given, and show what is on offer' (#12) from claude/layout-audit into main 2026-09-21 13:30:53 -04:00
Waters ChurchandClaude Fable 5.1 a7e40ee1a8 Layouts keep every tile they are given, and show what is on offer
The bug behind this: for seventeen days the server rebuilt each saved tile
from the fields it knew and dropped the rest — including `variant`, so a
layout of five different timers came back from the server as five "All
timers". The client had carried `variant` since multi-tile modules arrived;
the server was the one that lagged, and it is the part of ProdDash that
always will (a booth updates weeks after the dev machine). So the server
stops deciding which tile fields exist: it checks the shape of the ones it
knows, keeps any other small plain-JSON field a tile carries, drops only
retired ones, stamps the file `v: 2`, and refuses a layout over a hundred
tiles instead of quietly saving the first hundred. A test round-trips a
fully-populated tile plus one the server has never heard of, and fails if
anything is lost.

Then the rule the arrangement is loaded by. A layout keeps every tile it is
given; the board shows the ones whose module is installed and enabled and —
for a tile added from a picker entry — whose entry is offered right now. The
rest keep their place and draw nothing, and appear the moment the registry
offers them again: a Service timer waits for PCO Plan, a transcript for its
channel. Never the module's default in their place. Because a module's entry
list can change with no event to mark it (PCO Plan comes back and, seconds
later, the timers module is offering its timers again), the registry is
re-read every few seconds while anything is folded away, and not at all
otherwise. The timers module's placeholder card for a missing solo timer
goes with this: nothing is drawn, the dot says why.

Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service,
#ops aren't offered right now" — with Undo in the message. Saving over a
name asks first, and the server keeps the copy it replaced one back; Admin →
Saved layouts gets Restore previous, which swaps the two so a restore is one
restore from undone. The browser keeps one good copy of its own layout
behind the live one and falls back to it when the live one will not parse.

Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines
had their variants stripped before this and have to be saved again from a
browser that still holds the right tiles — nothing here recovers what the
files no longer contain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 12:02:34 -04:00
michael c075655817 Merge pull request 'An update that goes wrong is no longer the end of it' (#11) from claude/update-safety into main 2026-09-17 16:30:42 -04:00
michael 48d0f5a04a Merge pull request 'SPL Meter: measure the room, not just watch it' (#10) from claude/spl-meter-module into main 2026-09-17 16:18:11 -04:00
Waters ChurchandClaude Opus 5 12f6e22187 An update that goes wrong is no longer the end of it
Two things the audit found, both about the archive path every shipped .app
uses.

**Settings could be wiped.** The update kept config/modules.json and
config/layouts/ by name, but not config/proddash.json — which holds the admin
PIN, the theme and the forge pointer. On an install whose data directory IS
the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR
aimed inside the app), an update wrote the repo's shipped defaults over it:
the theme went, and admin silently unlocked. Nothing inside the data
directory is written now, wherever it happens to sit, and the two legacy
names stay listed for an install whose state never moved out.

**A bad update was unrecoverable.** No rollback, and a fresh .app would not
help: seeding only replaces an *older* copy, and a broken one carries a
higher version — so the fix was deleting a folder nothing told you about.
Three changes, in the order they catch things:

- The download must parse before anything is swapped in. `node --check`
  against the new server.js, and a failure is reported with the syntax error
  and leaves the running copy untouched.
- Past that, the copy being replaced is set aside as `app.previous`, and
  dropped once the new version has stayed up ninety seconds — so that folder
  existing means "the last update has not proven itself yet", and never
  anything older.
- The launcher, which already gives up after four stops in two minutes, now
  looks for that folder before it does and puts the old copy back. Once: if
  the old one will not run either, the update was not the problem.

The launcher half only reaches a machine with a new .app; the server half
travels by the normal update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:16:46 -04:00
Waters ChurchandClaude Opus 5 87929bfd0b Don't take the server down when the module unmounts
Deploying 0.3.0 to AB crashed the ProdDash server on restart:

  TypeError: Cannot read properties of null (reading 'streams')
      at server.js:273  req.on('close', () => state.streams.delete(res))

teardown() nulls `state` and then ends every open stream, which fires each
request's close handler — and the handler reached back through the `state`
it had just discarded. Any re-init with a browser connected would do it, so
saving admin config while a dashboard was open was enough to stop the whole
server, every module with it.

The stream handler now closes over this mount's Set rather than the module's
state: after teardown it deletes from a set nobody reads, which is harmless.
The same hazard existed anywhere a callback could outlive the mount — the
keepalive ping, a frame arriving from the listener mid-teardown, and the
/measure interval — and each is closed over or guarded now.

Reproduced first (open an SSE client, save config, watch it fall over) and
confirmed fixed against the same sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:06:47 -04:00
Waters ChurchandClaude Opus 5 46d44d494b Keep the banding caveat where the band plan is built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:00:34 -04:00
Waters ChurchandClaude Opus 5 44fd0b10ba SPL Meter 0.3.0: bring the tiles to the interface rules
Rebasing onto main landed rules 5 to 11, which this module predates and
broke in three places.

Rule 5 — no explanatory text in a tile's gear: the Banding and Smoothing
fields each carried a paragraph. The Banding one said something true and
worth knowing, that below roughly 200 Hz the window length limits real
resolution however fine the setting, so it moves to the source where the
band plan is built rather than being lost. Option labels shortened with it.

Rule 11 — one control per action: the spectrogram offered its axis both as
a gear field and as a title-bar button. The button stays, since that is
what was asked for, and the field is gone. The setting still persists with
the tile; saveInstanceSettings keeps keys that no schema declares.

Rule 9 — title-bar controls are icons: the axis button was drawing a "⇆"
as a label. It is an inline SVG now, with the tooltip carrying the words
and naming the current direction rather than the action.

Rule 8 also asks that a tile can switch off what it draws, so the readout
gained a Caption toggle and both graphs gained Labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:00:17 -04:00
Waters ChurchandClaude Opus 5 543795f9fd Let the SPL readout fill its tile
The number was capped at 6rem, so a readout given half a wall stopped
growing at the size it reached in a small tile.

Removing the cap was not enough on its own. Sizing text off container units
means choosing a coefficient for some assumed number of characters, and the
value here is not a fixed width: the coefficient that fills a tile nicely
for "-45.9" pushes "-100.5" nine percent out through the side. Measured it
rather than guessed — the tile now sets a trial size, reads the width the
actual text wants, and scales to fit. That runs only when the tile size or
the length of the reading changes, not on every frame.

It now fills about 98% of the width wherever width is the limit and 92% of
the height wherever height is, at every tile size from 180x70 up to
1400x700, with no overflow on either axis. The CSS keeps a conservative
size for the first paint before the measurement has run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 d2d4207fe4 SPL Meter 0.2.0: one measurement, many views
Every control asked for here is a view choice, so none of them may reach
the capture child — two dashboards watching the same room must be able to
disagree about how to draw it without disturbing the measurement or each
other. That set the shape of the change: the child now sends everything
anyone might ask for, once, and each tile folds it down.

Averaging (was "Reading") is Fast, Slow, Inf and None, and the child
computes and sends all four every frame. Fast and Slow are the IEC time
constants, Inf is Leq since reset, None is the block with no time constant
at all. The admin-wide time constant is gone — it was never a property of
the room.

Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and
because every banding offered divides 48 a coarser band is an exact sum of
finer ones in the power domain. Nothing is resampled and no tile's choice
is visible to another. The transform grew to 16384 points to have bins fine
enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than
one bin, which the Banding help says out loud rather than pretending.

Each tile kind now carries its own settings, which is the fix for the
complaint that started this: the RTA was offering an alarm threshold and a
metric picker that did nothing to it. An entry's instanceSchema replaces the
module's, so a gear popover only holds controls that change that tile.

Also: frequency labels in three tiers that thin out as a tile narrows
instead of smearing, gridlines off by default on both graphs, RTA smoothing
as a damping factor on the drawn value only, a title-bar toggle to swap the
spectrogram's axes, and the threshold blurb removed.

Settings saved by 0.1.0 are mapped forward — a layout on a wall should not
start reading dashes because a field was renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 e90ae51495 Never build the capture tool on the event loop
The missing-binary fallback was a spawnSync, copied from the LTC listener
where the source is a third the size and builds in a few seconds. On the
production Mac this file took nearly two minutes, and spawnSync held the
Node event loop for every second of it: one module's optional helper froze
the entire dashboard, every other module with it, and then did it again
thirty seconds later on retry.

The build is now a plain spawn that reports "building" and calls start()
itself when it lands, so nothing waits on it. A failed build says so once
instead of spinning, and a build that never finishes is given up on after
five minutes.

The real lesson is the one in the comment: the binary should already be
there. It is built beside the app, or copied from any Mac of the same
architecture. Building on the machine that might be running a service is
the fallback, not the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 188d1c878d SPL Meter module: measure the room, not just watch it
A sound level meter that lives in ProdDash rather than beside it. Takes an
audio input the way the Timers module's LTC reader does, and offers four
tiles: the level as a big number, per-channel bars with peak hold, a
third-octave RTA, and a scrolling spectrogram.

The measurement happens in spl-capture.swift, not in Node and not in the
browser. LTC gets away with piping raw samples upstream because the decode
is cheap and single-channel; sound level is filtering and FFTs over every
channel at the device's rate, so the child does the work and only the
answers cross the pipe. That costs nothing in dependencies — Accelerate
ships with macOS, and the whole thing builds in two seconds with a plain
swiftc, exactly like ltc-capture.

The weighting filters are the part worth checking rather than trusting, so
`spl-capture --selftest` prints them against the IEC 61672 table. A- and
C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at
1 kHz. The top octave is where the bilinear transform runs out of room: at
48 kHz the 16 kHz point sits about 3 dB low even with the pole pair
prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a
broadband number and a real limit all the same, which is why the selftest
prints both rates instead of a pass mark.

Cross-checked against REW on the same microphone: after removing the fixed
calibration offset the two agree to a standard deviation of 0.5 dB on live
room noise.

Uncalibrated is a state the module says out loud — every tile reads dBFS
and labels it, so nobody mistakes a relative number for sound pressure. The
/measure route averages the calibrator tone in the power domain and hands
back the offset to paste into settings; a module cannot write its own admin
config, so that last step stays manual on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
michael 5025e12b71 Merge pull request 'Rules for a clean interface, and the modules brought to them' (#9) from claude/module-rules into main 2026-09-17 15:52:59 -04:00
michael 3009b39453 Merge pull request 'Twelve themes, and the Theme section folds away' (#8) from claude/themes into main 2026-09-17 15:52:24 -04:00
michael 41f64f3a13 Merge pull request 'Admin PIN: the fields say enough' (#7) from claude/admin-pin-trim into main 2026-09-17 15:51:34 -04:00
Waters ChurchandClaude Opus 5 12f0b20e5f Rules for a clean interface, and the modules brought to them
Seven rules join the four in the module guide, as rules 5 to 11: no
explanatory text in a tile's gear and no label over three words; server-side
settings in configSchema only; a tile setting changes that tile alone;
hide-toggles ungrouped when there are four or fewer and in a folded "Show"
group when there are five or more; title-bar controls are icons with
tooltips, never names; anything needing words belongs in the gear; and one
control per action, either place but not both.

Two passages said the opposite and now say the same thing — "keep labels
short and put the explanation there" is scoped to configSchema, and "icon
buttons, short labels" becomes icons with no labels at all. CLAUDE.md
carries the summary.

Then the modules. Nothing here changes a setting key or a default, so saved
layouts, named layouts and every tile already on a wall keep working; group
names, labels, help and fold state are presentation and are not stored.

- pco-plan: sixteen labels cut to three words or fewer ("Tint items by type
  (song / media / other)" is now "Tint by type"), "Show / hide" renamed
  "Show" and folded. Its one remaining help line stays by request: the top
  bar's template needs its separator syntax spelled out, which the variable
  chips beside it cannot convey, so rule 5 carves out template fields.
- propresenter-timers: the "Timers ▾" menu was the only named control left
  in any title bar. It is a stopwatch icon now, with the count riding
  alongside it while some timers are hidden — setLabel() writes over the
  button's contents, so the count needed ProdCom's badge treatment rather
  than a label. "Show" folds; the Colours help goes.
- propresenter-now-next: "Show" folds, in the manifest and in the Now
  entry's own copy.
- prodcom-transcript: "Show channel icons" loses the redundant verb.

slack and clock were already compliant and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:22:06 -04:00
Waters ChurchandClaude Opus 5 f72170b4e1 The folding section reads as a heading first
Theme's title was coming out at the normal weight — <summary> does not
inherit an <h1>'s boldness, so sitting it under .admin-h1 got the size,
colour and spacing but not the type. It now matches the other section
headings exactly.

The caret moved to the right of the words. A marker in front pushes the
heading out of line with every other one on the page; behind it, the section
still reads as "Theme" first and the caret only says there is more.

Written as the pattern rather than as Theme's own styling: any section that
folds is a <details class="admin-fold"> headed by a
<summary class="admin-h1 admin-fold-title">, and the whole row is the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:22:11 -04:00
Waters ChurchandClaude Opus 5 ea1eac1c28 Twelve themes, and the Theme section folds away
Seven more palettes: Midnight (indigo, violet), Moss (forest floor, lime),
Plum (aubergine, purple), Sepia (warm brown with a cool teal — Ember's
arrangement reversed), Blackout (true black for a dark room, where any glow
off the screen carries), Paper (the warm light one, next to Daylight's cool
white) and Iron, which has no colour of its own: its accent is near-white, so
the only hues on screen are a module's own and the red and amber that mean
something.

Each holds the line the first five set. Text on panel lands between 14.8:1
and 19.8:1, muted between 5.8:1 and 7.4:1, and every accent clears its own
theme's danger red and warning amber by more than Ember already does — the
narrowest of the new ones is Moss at 54 degrees of hue, against Ember's 18.

The Theme section now starts folded, wearing the same disclosure triangle as
a module's config groups. A building picks its palette once and then wants
the rest of the page, which was getting further down the screen with every
swatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:16:47 -04:00
Waters ChurchandClaude Opus 5 ea8be2ac76 Admin PIN: the fields say enough
The paragraph under the PIN fields explained what the PIN covers and how to
clear it by hand — and only someone already past the PIN could read it, so
the recovery note was never where it was needed. Gone, with the dead style
and the element lookup that hung off it.

The live line under the heading stays: it is the only thing that says
whether admin is locked, and the only warning that a passcode in the
server's environment outranks whatever is set here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:05:53 -04:00
michael 82e6386c73 Merge pull request 'Shell 1.6.0: bars that tuck away, and every edge resizes' (#6) from claude/tile-chrome into main 2026-09-17 12:44:07 -04:00
michael f5eb611872 Merge pull request 'README: the Slack module joins the list of included modules' (#5) from claude/readme-slack-module into main 2026-09-17 12:43:31 -04:00
michael d52cfcc48e Merge pull request 'Slack 1.2.1: a clearer Send tile, and a send target you can see' (#4) from claude/slack-send-target into main 2026-09-17 12:42:48 -04:00
michael 7d46cfbd8f Merge pull request 'ProPresenter 1.1.0: a Now tile, and switches for everything on it' (#3) from claude/propresenter-now into main 2026-09-17 12:41:53 -04:00
michael 7a5b57dc56 Merge pull request 'Admin sits behind a 6-digit PIN' (#1) from claude/admin-pin into main 2026-09-17 12:40:31 -04:00
Waters ChurchandClaude Opus 5 7f50fa0ba9 A pin on the title bar, and no more notch
Pointing at a tile's top edge already brings its bar back, which left the
notch doing half a job: it could still hide a bar, but hiding one tile at a
time stopped being the interesting question once the whole dashboard could
fold its bars at once. It is gone.

In its place, a pin beside each tile's label. Pinned, that tile keeps its
bar while auto-hide is on — for the one or two tiles whose controls are
wanted at a glance. It sits next to the label rather than out with the gear
and ✕ because it is about the bar, not about the tile's contents, and it
stays faint until the tile is pointed at or the pin is on, so a wall of
unpinned tiles is not a wall of pins.

Fullscreen keeps its own rule: every bar folds, pinned or not, because that
is a mode for watching. What it loses with the notch it gets back better —
the same hover at a tile's top edge peeks a bar there too, and now as an
overlay, so peeking no longer pushes the tile's content down and disturbs
the auto-fit. The peek timer the notch needed goes with it.

Per-tile `headHidden` gives way to `headPinned` in the layout, on disk and
on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:04:59 -04:00
Waters ChurchandClaude Opus 5 68f0ba806d Tiles resize from anywhere, so nothing marks the spots
Every edge and both bottom corners resize now, so the little marks that used
to appear on hover were pointing at the whole rim. They are gone — corners,
edges and the seam pill alike — and the cursor over an edge is what says it
can be dragged, the way a window's own edges do it. The seam still shows
itself while it is actually being dragged, which says what is moving rather
than where you may push.

With nothing drawn, the grips could be made to cover the rim properly: the
top strip now runs corner to corner instead of stopping 24px short, the
sides run from it down to the bottom corners, and the bottom strip fills
between those. No dead spots left along an edge.

The side grips also stopped standing down at a seam. They only did so to
avoid stacking three marks in one place, and it cost real ground: a tile
taller than its neighbour lost its whole side, not just the part the seam
covers. The seam sits above them anyway, so it still wins where it exists
and the rest of the edge now works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:52:45 -04:00
Waters ChurchandClaude Opus 5 8e579ecb34 Shell 1.6.0: bars that tuck away, and every edge resizes
Three things about arranging a dashboard.

**Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away
at once, for a windowed screen that should read like a wall display.
Fullscreen already did this for watching; this is the windowed equivalent,
asked for by hand. It starts off and saves with the layout, so a booth's
named layout loads with the bars already tucked away.

**A hidden bar is not a gone bar.** Folded away — by that switch or by a
tile's own notch — the bar leaves the flow and parks just above its tile, so
the module keeps the whole body and nothing reflows as the bar comes and
goes. Point at the tile's top edge and it slides back in over the content,
where its buttons click and its width drags the tile exactly as before. A
dashboard can now be arranged with every bar out of the way, which is the
point of hiding them.

**Every edge resizes.** The top, left and right edges join the bottom one:
each moves only itself, so the tile grows into the space beside it while the
opposite edge stays put, stopping at a neighbour or at the module's own
minimum. Where two tiles meet, that boundary already belongs to the seam
pill between them — it moves both edges and leaves no gap — so the two
tiles' side marks step aside there rather than offer a second, different
control on the same pixels.

One fix underneath: saving a named layout was dropping each tile's variant,
title, minimum size and hidden-bar state, so a saved "Now" tile came back as
the full Now/Next card and a solo timer came back with a full tile's
settings. The layout file now carries them — and the new switch with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:39:34 -04:00
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
michael 6cca6d4d1a Merge pull request 'The church's own Gitea is where ProdDash lives' (#2) from claude/gitea-is-home into main 2026-09-16 19:00:51 -04:00
Waters ChurchandClaude Opus 5 1be5cbef31 The church's own Gitea is where ProdDash lives
The shipped defaults pointed at GitHub, so a fresh install with no
configuration would have looked there — and that repository is going away.
They now name michael/ProdDash on chronicles-ab.waterschurch.org:3000, which
a fresh install was verified to reach with nothing configured at all.

The hostname rather than the IP, because the UniFi DNS record is
authoritative and this survives the NAS changing address. Both production
machines already carry an explicit setting of their own, so neither is
affected either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:00:22 -04:00
Waters ChurchandClaude Opus 5 7690ae1fad Merge main into the admin PIN branch
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:57:27 -04:00
Michael McGary 1c8cd63cd0 Merge pull request #20 from michaelmcgary2008/claude/shell-1-5-0
Shell 1.5.0
2026-09-16 18:25:02 -04:00
Waters ChurchandClaude Opus 5 af6322d079 Shell 1.5.0: updates can come from your own git server
The forge setting is a new capability, so the version moves — which is
also how a machine already running 1.4.0 is offered the update at all
(an archive install compares versions, having no commit of its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:24:57 -04:00
Michael McGary 168f717398 Merge pull request #19 from michaelmcgary2008/claude/self-hosted-forge
Updates can come from your own git server
2026-09-16 18:23:38 -04:00
Waters ChurchandClaude Opus 5 6dabd9d8a1 Admin sits behind a 6-digit PIN
The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.

- Six digits, stored as a scrypt hash in this machine's proddash.json —
  never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
  Five wrong tries from one address locks it out for 30 seconds, and the
  right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
  left open on the admin page cannot be used to take the machine over.
  Whoever sets it keeps their session rather than being bounced to the
  card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
  Dashboards stay open, and it is unrelated to a module's PIN — Slack's
  4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
  (which overrides the PIN), or delete adminPin from proddash.json and
  restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
  keeps running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:46:41 -04:00
44 changed files with 6817 additions and 487 deletions
+3
View File
@@ -14,3 +14,6 @@ modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds # the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/ launcher/macos/build/
launcher/macos/.cache/ launcher/macos/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
+7
View File
@@ -32,12 +32,19 @@ title-bar controls). The rules that matter most:
and bump the module's `version` on every change. and bump the module's `version` on every change.
- Browsers never call upstream services directly; the module's server routes - Browsers never call upstream services directly; the module's server routes
proxy everything (no CORS, secrets stay server-side). proxy everything (no CORS, secrets stay server-side).
- **Keep the interface clean** (guide rules 5-11): no `help` and no label
over three words in a tile's gear; server-side settings in `configSchema`
only; a tile setting changes that tile alone; hide-toggles ungrouped when
there are four or fewer, in a folded **Show** group when there are five or
more; title-bar controls are icons with tooltips, never labels, and never
repeat something the gear already offers.
- Style with the shell's CSS variables only, scoped under - Style with the shell's CSS variables only, scoped under
`[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`). `[data-module="<id>"]`; survive upstream loss (reconnect + `setStatus`).
- Verify against the guide's testing section and resilience checklist. Mocks - Verify against the guide's testing section and resilience checklist. Mocks
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter). `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
## Reference code (read-only forks — don't develop these here) ## Reference code (read-only forks — don't develop these here)
+143 -28
View File
@@ -99,9 +99,18 @@ with this machine's shell settings — the same keys as the checked-in
| Key | Meaning | | Key | Meaning |
| --- | --- | | --- | --- |
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | | `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
| `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`. | | `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` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | | `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). |
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
overrides the PIN and always gets you in.
Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a
wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's
4–8 digit send PIN, say), which guards that module's actions only.
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the **Updating from your own git server.** ProdDash fetches one `tar.gz` of the
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
@@ -131,12 +140,26 @@ ignored from then on.
The whole dashboard — every tile, on every browser — draws with one palette, The whole dashboard — every tile, on every browser — draws with one palette,
chosen in the admin page's **Theme** section and switched live: pick a swatch chosen in the admin page's **Theme** section and switched live: pick a swatch
and open dashboards change without a reload. Five presets ship: **Booth** and open dashboards change without a reload. The section starts folded —
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite** a building picks its palette once — and holds twelve presets:
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
and **Daylight**, a light palette for a front-of-house desk in daytime. Each | | |
keeps the status colours apart — the accent is never near the danger red or |---|---|
the warning amber — and muted text readable in a dark booth. The choice is | **Booth** | the default dark, mint accent |
| **Harbor** | deep navy, cyan |
| **Graphite** | neutral charcoal, electric blue |
| **Ember** | warm broadcast black, orange |
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
| **Midnight** | indigo night, violet |
| **Moss** | forest floor, lime |
| **Plum** | aubergine, purple |
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
| **Blackout** | true black, cyan, for a dark room where any glow carries |
| **Paper** | the warm light one, indigo accent |
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
Each keeps the status colours apart — the accent is never near the danger red
or the warning amber — and muted text readable in a dark booth. The choice is
stored as `theme` in the data directory's `proddash.json` (table above), so stored as `theme` in the data directory's `proddash.json` (table above), so
it holds across restarts and updates; a browser remembers the last theme it it holds across restarts and updates; a browser remembers the last theme it
saw and applies it before the server answers, so nothing flashes. Modules saw and applies it before the server answers, so nothing flashes. Modules
@@ -145,10 +168,11 @@ set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
## Installing modules and updating ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub Everything below happens in the **admin page**; ProdDash talks to its git
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with repository — `michael/ProdDash` on the church's own Gitea at
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable, `chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
`owner/repo#branch`). It downloads one small archive of the branch, at most `repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
`PRODDASH_REPO` variables (`owner/repo#branch`). It downloads one small archive of the branch, at most
every few minutes, only while the admin page is open or for the update check every few minutes, only while the admin page is open or for the update check
below. Nothing is installed without someone clicking. below. Nothing is installed without someone clicking.
@@ -176,7 +200,18 @@ below. Nothing is installed without someone clicking.
no local changes — otherwise the banner says why and you update with git). no local changes — otherwise the banner says why and you update with git).
A plain folder (no `.git`) has the repo archive unpacked over it; files A plain folder (no `.git`) has the repo archive unpacked over it; files
removed upstream are left behind but harmless. Settings and layouts are removed upstream are left behind but harmless. Settings and layouts are
never touched. **What changed** opens the commit comparison on GitHub. never touched — nothing inside the data directory is written, even on an
install where that directory *is* the app's own `config/`. **What changed**
opens the commit comparison on the forge.
- **If an update goes wrong**: the download has to parse before anything is
swapped in, so a truncated or mangled `server.js` is refused with the
reason and the running copy is left exactly as it was. Past that, the copy
being replaced is set aside as `app.previous` next to the app folder, and
dropped once the new version has stayed up for ninety seconds — so that
folder existing means "the last update has not proven itself yet". The
macOS app watches for a version that will not stay up (four stops in two
minutes) and puts the old one back by itself, once, with a notification.
Running some other way, the way back is a rename: `mv app.previous app`.
- **Restarting**: after an update the server exits with code 75 and starts - **Restarting**: after an update the server exits with code 75 and starts
its own replacement, which waits for the port to free up. If you supervise its own replacement, which waits for the port to free up. If you supervise
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
@@ -188,13 +223,17 @@ below. Nothing is installed without someone clicking.
- **+** — add any enabled module; multiple tiles of the same module are - **+** — add any enabled module; multiple tiles of the same module are
fine (e.g. two transcripts filtered to different channels). fine (e.g. two transcripts filtered to different channels).
- Drag a tile by its **title bar** to move it; drag either **bottom - Drag a tile by its **title bar** to move it; **✕** removes it.
corner** to resize; **✕** removes it. Hover a tile and a small pill - **Resize from any edge or bottom corner**, the way a window resizes:
appears on its **bottom edge** — drag it to change just the height. If nothing is drawn on a tile to mark the spots — the cursor over an edge is
another tile sits directly below, the pill moves the shared edge instead, what says it can be dragged. An edge moves only itself, so the tile grows
trading rows between the two so the column keeps its total height. Tiles or shrinks into the space beside it while the opposite edge stays put,
sit flush — when two share a vertical edge, hover the seam and the same stopping at a neighbour or at the module's own minimum size. Two
kind of pill appears; drag it to trade width (macOS split-view style). exceptions, both of which move a *shared* edge instead: with another tile
directly below, the bottom edge trades rows between the two so the column
keeps its total height; and where two tiles meet side by side, the seam
between them trades width (macOS split-view style), showing itself only
while you drag it.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its - **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the occupied columns stretch to the full width, and every column runs to the
@@ -204,9 +243,21 @@ below. Nothing is installed without someone clicking.
gap; alone in its columns it stays as it is. Nothing is saved — the layout gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls. its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**; - A tile's actions (menus, text size, clear…) live in its **title bar**.
the small **notch** on the bar's bottom edge hides/shows the whole bar - **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
for a clean wall-display look. The menu bar's handle is the **ProdDash a windowed screen that should read like a wall display. A folded bar is
not a gone bar: point at the tile's top edge and it slides back in over
the content, where its buttons click and its width drags the tile exactly
as before — so a dashboard can be arranged with every bar out of the way.
The switch is off on a fresh dashboard and saves with the layout —
including a named layout on the server, so a booth loads its screen with
the bars already tucked away. Fullscreen folds every bar regardless, and
the same hover brings one back there too.
- The **pin** beside a tile's title keeps *that* tile's bar showing while
auto-hide is on — for the one or two tiles whose controls are wanted at
a glance. It turns the accent colour when it is on and saves with the
layout. Fullscreen still folds a pinned bar with the rest.
- The menu bar's own handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's the bar back. It stays at the left so it never sits on top of a tile's
@@ -215,7 +266,21 @@ below. Nothing is installed without someone clicking.
display options) — these belong to this browser's layout, not the server. display options) — these belong to this browser's layout, not the server.
- The **grid icon** — save the current arrangement to the server under a - The **grid icon** — save the current arrangement to the server under a
name, load a named layout (it becomes a local copy — browsers are never name, load a named layout (it becomes a local copy — browsers are never
linked), or reset this browser's dashboard. 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
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 —
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
4K wall), coarser suits a small screen read close up; row height follows,
so tiles keep their shape and simply change size. Going coarser, a tile
that no longer fits where it was moves to the first free spot rather than
onto another tile. It saves with the layout, so a named layout comes back
at the density it was arranged on.
- Every tile has a **status dot**: green = live, amber = connecting, - Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect red = upstream unreachable (hover it for details). Tiles reconnect
automatically. automatically.
@@ -230,7 +295,13 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps. Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
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, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every 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" discovered timer (and the LTC card) as its own tile, or add "All timers"
@@ -257,13 +328,52 @@ below. Nothing is installed without someone clicking.
description and item notes. When the ProPresenter module is running it description and item notes. When the ProPresenter module is running it
follows that module's live item, highlights the matching plan item, dims follows that module's live item, highlights the matching plan item, dims
what's done and shows the elapsed vs planned runtime and how far what's done and shows the elapsed vs planned runtime and how far
ahead/behind the service is. Admin config is the connection: Planning ahead/behind the service is. Items are matched by name — a document named
like the Planning Center item or its song, tags and all. The message is
the exception: a sermon's document is named after the sermon, so a live
document that reads like the plan itself — its date code, series title
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
message and lands on the plan's message item (an item titled Message,
Sermon or Teaching, else the first item under such a header). Admin
config is the connection: Planning
Center credentials (Personal Access Token app ID + secret, or an OAuth Center credentials (Personal Access Token app ID + secret, or an OAuth
token), the service type (a list grouped by your Planning Center folders) token), the service type (a list grouped by your Planning Center folders)
and an optional pinned plan. Everything about the display is per tile and an optional pinned plan. Everything about the display is per tile
(gear menu): the top-bar text (placeholders such as (gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a `{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail. 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
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).
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.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
@@ -271,10 +381,15 @@ 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`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on `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 `127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js` timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with (Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the `PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
[module guide](docs/MODULE-GUIDE.md). `node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
board — a plan, its team members, birthdays and photos — run ProdDash with
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app ## Install as a desktop web app
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"port": 24500, "port": 24500,
"adminPasscode": "", "adminPasscode": "",
"forge": "github", "forge": "http://chronicles-ab.waterschurch.org:3000",
"repo": "michaelmcgary2008/ProdDash", "repo": "michael/ProdDash",
"branch": "main" "branch": "main"
} }
+78 -24
View File
@@ -34,6 +34,46 @@ people running the booth depend on them.
The admin page refuses to install a module whose requirement the running The admin page refuses to install a module whose requirement the running
shell doesn't meet, and it offers updates by comparing versions. shell doesn't meet, and it offers updates by comparing versions.
### Keeping the interface clean
A dashboard is read across a booth, at a glance, by someone doing three
other things. These rules are what keep every tile looking like one product
instead of six. They are as binding as the four above.
5. **No explanatory text in a tile's gear.** No `help` on an
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
words at most, one or two preferred** — "Slide count", "Item tags",
"Running clock". A control that cannot be understood in three words is
not a display choice; it belongs in `configSchema`, where `help` is
welcome and expected. *One exception:* a `template` field may keep a
single `help` line naming its placeholders, which nobody can guess.
6. **Server-side settings are admin-only.** Anything the module's server
acts on — a host, a credential, what to fetch, how often to poll — is
`configSchema`. `instanceSchema` is display choices and nothing else:
what this tile shows, and how big.
7. **A tile setting changes that tile and nothing else.** Never another
tile, never the module as a whole, never the server. If two tiles must
agree on something, it is either admin config or page state the shell
owns — not a setting each tile keeps its own copy of.
8. **Offer toggles for the elements you draw.** Every tile should let the
operator switch off the parts it does not need. **One to four toggles:**
give them no `group` at all, so they sit unlabelled at the top of the
gear. **Five or more:** one group named **Show**, `collapsed: true`,
`columns: 2`. Nothing else goes in that group — colours, formats and
text belong in groups of their own.
9. **Title-bar controls are icons.** No `label` on `addButton` or
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
the icon. A small state indicator may ride alongside an icon — ProdCom's
channel button shows a count while a filter is on — but a control is
never named in the bar.
10. **If a control needs words to explain it, it is not a title-bar
control.** Put it in the gear. The bar is for actions whose icon and
tooltip say everything: refresh, clear, jump to latest, bigger text.
11. **One control per action.** Whatever has a dedicated title-bar button is
not offered again in the gear, and the reverse. Text size is A− / A+ up
there, never a "Text size" field; a timestamps button in the bar means
no timestamps switch below it.
## The shape of a module ## The shape of a module
A module is one folder dropped into `modules/`: A module is one folder dropped into `modules/`:
@@ -96,7 +136,7 @@ the app); its settings are kept for a later reinstall.
| `client` | yes | Client entry file, loaded as an ES module. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. | | `style` | no | A stylesheet the shell injects once per module. |
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | | `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | | `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. | | `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. | | `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
@@ -177,14 +217,18 @@ the group name:
`help` on a field puts one line of guidance under its control — allowed `help` on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the placeholders, where a value comes from. Keep labels short and put the
explanation there. explanation there. **This is for `configSchema` only**: a tile's gear
carries no explanations at all (rule 5), so a setting that needs one is
telling you it belongs in admin.
`group` and `help` work on `instanceSchema` fields too, and a manifest `group` works on `instanceSchema` fields too, and a manifest
`instanceGroups` map plays the `configGroups` role for the tile's gear `instanceGroups` map plays the `configGroups` role for the tile's gear
popover (`collapsed: true | false`, `columns`, `help`, `toggle`). That is how popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
a module with a long show/hide checklist keeps the popover usable — see per rule 5). That is how a module with a long show/hide checklist keeps the
`pco-plan`. Display choices belong there, per tile; admin config is for popover usable: five or more hide-toggles go in one **Show** group, folded
connections. The popover itself is a small window beside the tile — sized to by default, and fewer than five go in no group at all — see `pco-plan` for
the long form and `slack` for the short one. Display choices belong there,
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
its content, moved by the slim bar on top, closed by clicking anywhere else its content, moved by the slim bar on top, closed by clicking anywhere else
or pressing Escape — so a tile never has to be big enough to hold its own or pressing Escape — so a tile never has to be big enough to hold its own
settings, and a change can be watched as it lands. Under everything you settings, and a change can be watched as it lands. Under everything you
@@ -354,22 +398,25 @@ menu.setLabel('Channels (2/4) ▾'); // update the button text any time
``` ```
Everything you add is removed automatically when your instance stops, so a Everything you add is removed automatically when your instance stops, so a
remount never duplicates controls. Keep them compact (icon buttons, short remount never duplicates controls. **Icons only, no labels** (rule 9): the
labels): the title bar is 32 px tall and shared with the tile's name. Draw title bar is 32 px tall and shared with the tile's name, and the tooltip
icons as inline SVG rather than characters — a glyph such as ✕ or + takes says what the icon means. Draw icons as inline SVG rather than characters —
its size from whatever font each browser picks; text-size buttons are a a glyph such as ✕ or + takes its size from whatever font each browser
drawn − and +. picks; text-size buttons are a drawn − and +. A small state indicator may
ride alongside an icon (ProdCom's channel button carries a count while a
filter is on); a name may not.
**One control per action.** Whatever has a dedicated header button is not **One control per action** (rule 11). Whatever has a dedicated header button
offered again in the gear popover: text size is A− / A+ up here (persisted is not offered again in the gear popover: text size is − / + up here
through `saveInstanceSettings({ textSize })`, read back with a sane (persisted through `saveInstanceSettings({ textSize })`, read back with a
fallback), never a "Text size" field in `instanceSchema`; a timestamps or sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
newest-first toggle in the header means no such switch in the gear. The or newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own. gear is for display choices that have no button of their own.
One more thing to know: the user can hide the whole title bar with the One more thing to know: the whole title bar folds away when the dashboard
small notch on its bottom edge (a clean-view mode for wall displays). Your auto-hides it, and always in fullscreen (a clean-view mode for wall
controls disappear with it, so nothing essential — status, reconnects, displays) — it comes back only while the pointer is at the tile's top edge.
Your controls fold away with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control. live data — may exist *only* as a header control.
`sse(path, handlers)` handlers: `sse(path, handlers)` handlers:
@@ -545,10 +592,17 @@ module.exports = {
- When the user adds an entry, its `settings` overlay your `instanceSchema` - When the user adds an entry, its `settings` overlay your `instanceSchema`
defaults in that tile's instance settings, the entry's `name` becomes the defaults in that tile's instance settings, the entry's `name` becomes the
tile's title, and `moduleApi.variant` carries its `id`. tile's title, and `moduleApi.variant` carries its `id`.
- The tile keeps its variant id and title even if the entry later vanishes - **A tile is shown only while its entry is offered.** The shell mounts a
from your list (a timer deleted in ProPresenter, a page removed in admin). tile added from an entry only when that entry (or one of its `aliases`) is
**Your client decides what that means** — show a clear "no longer in your list right now; otherwise the tile keeps its place in the layout,
configured" state rather than erroring. 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.
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
registry refresh. Never fall back to your default view in its place: five
different timers turning into five "All timers" is worse than five gaps.
- A client-only module can declare a fixed list as a `tiles` array in - A client-only module can declare a fixed list as a `tiles` array in
`module.json` instead (same entry shape). `module.json` instead (same entry shape).
- An empty list (or no `tiles` at all) keeps today's behavior: one picker - An empty list (or no `tiles` at all) keeps today's behavior: one picker
@@ -103,6 +103,45 @@ enum ProdDash {
} }
} }
/**
Go back to the copy ProdDash set aside before its last update.
A version that will not stay up is the one state this app cannot talk its
way out of: the admin page went down with it, and installing a fresh
`.app` does nothing, because seeding only replaces an *older* copy and a
broken one carries a higher version. So ProdDash writes `app.previous`
before it overwrites itself and drops it again once the new version has
stayed up — meaning that folder exists only while an update is still
unproven. This puts it back.
Returns the version restored, or nil when there is nothing to go back to.
*/
@discardableResult
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
let fm = FileManager.default
let previous = URL(fileURLWithPath: root.path + ".previous")
guard isRoot(previous) else { return nil }
let version = shellVersion(root: previous)
let named = version.isEmpty ? "(unknown version)" : version
let broken = URL(fileURLWithPath: root.path + ".broken")
do {
try? fm.removeItem(at: broken)
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
try fm.moveItem(at: previous, to: root)
makeWritable(root)
try? fm.removeItem(at: broken)
log("rolled ProdDash back to \(named) — the copy it replaced")
return named
} catch {
log("could not roll back to the previous copy: \(error.localizedDescription)")
// Leave something runnable behind rather than nothing at all.
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
try? fm.moveItem(at: broken, to: root)
}
return nil
}
}
/// Files copied out of a bundle can arrive read-only, and this copy is /// Files copied out of a bundle can arrive read-only, and this copy is
/// exactly where ProdDash writes its own updates. /// exactly where ProdDash writes its own updates.
private static func makeWritable(_ root: URL) { private static func makeWritable(_ root: URL) {
+19 -1
View File
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
private var restartAfterExit = false private var restartAfterExit = false
private var startedAt: Date? private var startedAt: Date?
private var crashTimes: [Date] = [] private var crashTimes: [Date] = []
/// One roll-back per bad update: a second would just swap copies forever.
private var rolledBack = false
private var pendingRestart: DispatchWorkItem? private var pendingRestart: DispatchWorkItem?
private var healthTimer: Timer? private var healthTimer: Timer?
private var healthMisses = 0 private var healthMisses = 0
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
child = nil child = nil
healthy = false healthy = false
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0 let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
if restartAfterExit { if restartAfterExit {
restartAfterExit = false restartAfterExit = false
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 } crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
guard settings.restartOnCrash, crashTimes.count < 4 else { guard settings.restartOnCrash, crashTimes.count < 4 else {
// Before giving up: if an update is still unproven, the copy it
// replaced is sitting right there. Going back beats sitting dark,
// and only once — if the old one will not run either, the problem
// is not the update.
if settings.restartOnCrash, !rolledBack, let root = rootURL,
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
rolledBack = true
crashTimes.removeAll()
state = .stopped
message = "Rolled back to ProdDash \(restored) — starting…"
Notifier.shared.post(title: "ProdDash rolled back",
body: "The new version would not stay up. Back on \(restored).")
refreshEnvironment()
start()
return
}
let why = settings.restartOnCrash let why = settings.restartOnCrash
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped." ? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
: "ProdDash \(how)." : "ProdDash \(how)."
+52
View File
@@ -0,0 +1,52 @@
# 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.
+299
View File
@@ -0,0 +1,299 @@
// Band Lineup — client. Renders the shared stage board from /state + /stream.
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
// into slots, plus rename / clear / upload / delete. Edits go to the server and
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
// (Wed→Tue) rains confetti.
const CONFETTI_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
export default function create({ root, moduleApi }) {
let stream = null;
let state = null;
let editing = false;
let choirBtn = null;
let selectedLibId = ''; // click-to-place fallback for touch
const canvases = new Set();
let raf = null;
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
let dragScrolling = false;
let dragY = 0;
let dragBody = null;
let dragRaf = null;
function startDragAutoScroll() {
if (dragRaf) return;
const tick = () => {
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
const r = dragBody.getBoundingClientRect();
const zone = 90; // generous edge band so you don't have to be pixel-accurate
let dy = 0;
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
if (dy) dragBody.scrollTop += dy;
dragRaf = requestAnimationFrame(tick);
};
dragRaf = requestAnimationFrame(tick);
}
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
/* ---------- confetti (always animates) ---------- */
function makeParticle(w, h, intensity, seedY) {
const ribbon = Math.random() < 0.55;
return {
x: Math.random() * w, y: seedY != null ? seedY : Math.random() * h,
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5, h: ribbon ? 9 + Math.random() * 8 : 0, ribbon,
color: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0],
rot: Math.random() * Math.PI * 2, vr: -0.14 + Math.random() * 0.28,
sway: 0.6 + Math.random() * 1.1, phase: Math.random() * Math.PI * 2,
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
};
}
function seed(c, w, h) { const base = c._intensity === 'subtle' ? 26 : 64; const n = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); c._parts = Array.from({ length: n }, () => makeParticle(w, h, c._intensity)); }
function step(c) {
const w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (c._w !== w || c._h !== h) { c.width = w * dpr; c.height = h * dpr; c._ctx = c.getContext('2d'); c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); c._w = w; c._h = h; seed(c, w, h); }
const ctx = c._ctx; ctx.clearRect(0, 0, w, h);
for (const p of c._parts) {
p.phase += 0.03; p.y += p.vy; p.x += Math.sin(p.phase) * p.sway; p.rot += p.vr;
if (p.y > h + 16) Object.assign(p, makeParticle(w, h, c._intensity, -16));
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color;
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
}
function loop() { for (const c of [...canvases]) { if (!c.isConnected) { canvases.delete(c); continue; } step(c); } raf = canvases.size ? requestAnimationFrame(loop) : null; }
function addConfetti(host, intensity) { const c = document.createElement('canvas'); c.className = 'bl-confetti'; c._intensity = intensity; host.appendChild(c); canvases.add(c); if (!raf) raf = requestAnimationFrame(loop); }
/* ---------- helpers ---------- */
function initials(name) { const p = String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); return p.length ? p.map((x) => x[0]).join('').toUpperCase() : ''; }
// Per-tile Columns wins; 0 falls back to the admin default.
function resolveCols() { const per = Number(moduleApi.instanceSettings.columns) || 0; return Math.max(1, per > 0 ? per : (Number(state && state.columns) || 7)); }
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function photoNode(person, cls) {
if (person && person.photo) { const img = el('img', cls || 'bl-img'); img.alt = ''; img.src = person.photo; img.loading = 'lazy'; return img; }
return el('div', 'bl-initials', initials(person && person.name));
}
async function api(pathStr, opts) { try { return await moduleApi.fetch(pathStr, opts); } catch { return null; } }
/* ---------- display board ---------- */
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
function render() {
stopConfetti();
stopParallax();
root.innerHTML = '';
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
if (editing) { renderEditor(); return; }
const board = el('div', 'bl-board');
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
const s = moduleApi.instanceSettings;
const birthdays = s.birthdays !== false;
const intensity = s.birthdayIntensity === 'full' ? 'full' : 'subtle';
const testIndex = birthdays ? String(s.birthdayTest || '') : '';
const cols = resolveCols();
const rows = Math.max(1, Math.ceil((state.slots ? state.slots.length : 1) / cols));
board.style.setProperty('--bl-cols', cols);
board.style.setProperty('--bl-rows', rows);
for (const slot of state.slots || []) {
const cell = el('div', 'bl-slot');
const person = slot.person;
if (person) {
const tile = el('div', 'bl-tile');
const photo = el('div', 'bl-photo');
photo.appendChild(photoNode(person));
const celebrating = birthdays && (person.celebrating || (testIndex !== '' && testIndex === String(slot.index)));
if (celebrating) { tile.classList.add('bl-birthday', `bl-birthday-${intensity}`); addConfetti(photo, intensity); }
if (s.showNames !== false && person.name) photo.appendChild(el('div', 'bl-name', person.name));
tile.appendChild(photo);
if (s.showLabels !== false && slot.label) tile.appendChild(el('div', 'bl-label', slot.label));
cell.appendChild(tile);
} else {
const empty = el('div', 'bl-tile bl-empty');
empty.appendChild(el('div', 'bl-label bl-label-empty', slot.label || `Slot ${slot.index + 1}`));
cell.appendChild(empty);
}
board.appendChild(cell);
}
root.appendChild(board);
applyParallax(board, s.parallax !== false);
reportStatus();
}
/* ---------- parallax: each photo drifts on its own, automatically ---------- */
let parallaxRaf = null;
let parallaxImgs = [];
function stopParallax() { if (parallaxRaf) { cancelAnimationFrame(parallaxRaf); parallaxRaf = null; } parallaxImgs = []; }
function applyParallax(board, on) {
stopParallax();
const imgs = [...board.querySelectorAll('.bl-img')];
if (!on) { imgs.forEach((i) => { i.style.transform = ''; }); return; }
// Give each image its own gentle, endless drift — different amplitude, speed and phase,
// so the wall never looks frozen and no two photos move together. No mouse needed.
parallaxImgs = imgs.map((img) => ({
img,
ax: 4 + Math.random() * 5, ay: 4 + Math.random() * 5,
// Very slow: a full drift cycle takes roughly a minute, so the motion only
// registers if you actually watch for it.
sx: 0.03 + Math.random() * 0.05, sy: 0.03 + Math.random() * 0.05,
px: Math.random() * Math.PI * 2, py: Math.random() * Math.PI * 2,
}));
const tick = () => {
const t = performance.now() / 1000;
for (const p of parallaxImgs) {
if (!p.img.isConnected) continue;
p.img.style.transform = `scale(1.08) translate(${(Math.sin(t * p.sx + p.px) * p.ax).toFixed(2)}px, ${(Math.cos(t * p.sy + p.py) * p.ay).toFixed(2)}px)`;
}
parallaxRaf = requestAnimationFrame(tick);
};
parallaxRaf = requestAnimationFrame(tick);
}
function reportStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (state.configured && !state.reachable) return moduleApi.setStatus('error', state.lastError || 'Planning Center unreachable');
return moduleApi.setStatus('ok', state.plan?.dates ? `Plan: ${state.plan.dates}` : 'Ready');
}
/* ---------- editor ---------- */
function renderEditor() {
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
// board on top and the picture picker (library) below it.
const wrap = el('div', 'bl-editor bl-editor-nobar');
const body = el('div', 'bl-editor-body');
// Feed the drag auto-scroller the pointer position anywhere over the editor.
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
// Slots (drop targets)
const boardEl = el('div', 'bl-edit-board');
boardEl.style.setProperty('--bl-cols', resolveCols());
for (const slot of state?.slots || []) {
const cell = el('div', 'bl-edit-slot');
cell.dataset.index = String(slot.index);
const photo = el('div', 'bl-edit-photo');
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
cell.appendChild(photo);
// An occupied card is a drag SOURCE too — move/swap it to another slot.
if (slot.person) {
cell.draggable = true;
cell.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
cell.addEventListener('dragend', stopDragAutoScroll);
}
const nameRow = el('div', 'bl-edit-namerow');
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
nameRow.appendChild(nameBtn);
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
cell.appendChild(nameRow);
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
// drop
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
cell.addEventListener('drop', (e) => {
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
const data = e.dataTransfer.getData('text/plain') || '';
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
});
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
boardEl.appendChild(cell);
}
body.appendChild(boardEl);
// Library
const lib = el('div', 'bl-library');
const list = state?.library || [];
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
for (const person of list) {
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
nm.addEventListener('click', (e) => { e.stopPropagation(); renameLibrary(person.id, person.name); });
item.appendChild(nm);
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
item.appendChild(del);
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
item.addEventListener('dragend', stopDragAutoScroll);
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
lib.appendChild(item);
}
body.appendChild(lib);
wrap.appendChild(body);
root.innerHTML = '';
root.appendChild(wrap);
}
function assign(slotIndex, personId) {
selectedLibId = '';
api(`/assign?slot=${slotIndex}&personId=${encodeURIComponent(personId)}`, { method: 'POST' });
}
function renameSlot(slotIndex, currentName) {
const name = prompt('Name for this spot (leave blank to remove a name-only spot):', currentName || '');
if (name === null) return;
api(`/slot/${slotIndex}/name?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
}
function renameLibrary(id, currentName) {
const name = prompt('Rename this photo:', currentName || '');
if (name === null) return;
api(`/library/${id}?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
}
/* ---------- lifecycle ---------- */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
const SYNC_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>';
const EDIT_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
moduleApi.header.addButton({
icon: SYNC_ICON,
title: 'Sync',
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
});
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
// Columns −/+ (touch-friendly; replaces the gear number field).
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
const bumpCols = (d) => {
const max = Math.max(1, state && state.slots ? state.slots.length : 12);
const next = Math.max(1, Math.min(max, resolveCols() + d));
moduleApi.saveInstanceSettings({ columns: next });
render();
};
moduleApi.header.addButton({ icon: MINUS_ICON, title: 'Fewer columns', onClick() { bumpCols(-1); } });
moduleApi.header.addButton({ icon: PLUS_ICON, title: 'More columns', onClick() { bumpCols(1); } });
const CHOIR_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V5l11-2v13"/><path d="M8.5 8.5l11-2"/></svg>';
choirBtn = moduleApi.header.addButton({ icon: CHOIR_ICON, title: 'Choir', onClick() { api('/choir', { method: 'POST' }); } });
moduleApi.fetch('/state').then((r) => r.json()).then((d) => { state = d.state; render(); }).catch(() => {});
stream = moduleApi.sse('/stream', {
error() { reportStatus(); },
events: {
state(e) {
try { state = JSON.parse(e.data); } catch { return; }
// While editing, don't yank a rename prompt out from under the user:
// re-render the editor only (state carries board + library).
render();
},
},
});
},
onResize() {},
stop() {
if (stream) { stream.close(); stream = null; }
stopConfetti();
stopParallax();
stopDragAutoScroll();
root.innerHTML = '';
},
};
}
+198
View File
@@ -0,0 +1,198 @@
'use strict';
/**
* Slot ↔ Planning Center position matching for the Band Lineup module.
*
* A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g.
* "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO
* team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign
* people to slots by loose token overlap — the same logic the backstage
* Electron app used, lifted here unchanged so the board matches what the booth
* is used to. Pure functions only: no I/O, so this file is unit-testable.
*/
function normalizeTeamName(value) {
return String(value || '').trim().toLowerCase();
}
/** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */
function isAllowedTeamName(value, keywords) {
const normalized = normalizeTeamName(value);
if (!normalized) return false;
return (keywords || []).some((kw) => {
const k = normalizeTeamName(kw);
return k && (normalized === k || normalized.includes(k));
});
}
function normalizeLabel(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */
function normalizeToken(token) {
const normalized = String(token || '').trim().toLowerCase();
if (!normalized) return '';
if (/^\d+$/.test(normalized)) return '';
if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return '';
if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
return normalized;
}
function labelTokens(value) {
const normalized = normalizeLabel(value);
if (!normalized) return [];
const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean);
if (!tokens.length && normalized) return [normalized];
return [...new Set(tokens)];
}
/**
* The role part of a slot label to match on: the bracketed content when present
* ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This
* drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't
* be mistaken for a position number.
*/
function slotRoleText(label) {
const brackets = String(label || '').match(/\[([^\]]*)\]/g);
if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', ');
return String(label || '');
}
/** The set of standalone numbers in a label ("vox 2" → {"2"}). */
function numbersIn(value) {
return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t)));
}
/** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */
function scoreMatch(slotLabel, positionLabel) {
const slotNormalized = normalizeLabel(slotLabel);
const positionNormalized = normalizeLabel(positionLabel);
if (!slotNormalized || !positionNormalized) return 0;
// A numbered role only matches the same number: "Vox 3" must not take "Vox 2".
const slotNums = numbersIn(slotLabel);
const posNums = numbersIn(positionLabel);
if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0;
if (slotNormalized === positionNormalized) return 120;
if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
const slotTokens = labelTokens(slotLabel);
const positionTokens = labelTokens(positionLabel);
if (!slotTokens.length || !positionTokens.length) return 0;
const positionSet = new Set(positionTokens);
const slotSet = new Set(slotTokens);
const overlap = slotTokens.filter((t) => positionSet.has(t));
if (!overlap.length) return 0;
const overlapCount = overlap.length;
const slotCoverage = overlapCount / slotSet.size;
const positionCoverage = overlapCount / positionSet.size;
const sharedPhrase = overlap.join(' ');
let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
if (slotCoverage === 1) score += 18;
if (positionCoverage === 1) score += 10;
if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
return score;
}
/**
* Assign scheduled members to the ordered slot list. Greedy by best score, each
* member used once. Returns an array aligned to `slots`, each entry the chosen
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
*/
const MIN_SCORE = 60;
function assignMembersToSlots(slots, members) {
const pool = members.slice();
return slots.map((slotLabel) => {
const role = slotRoleText(slotLabel);
let bestIndex = -1;
let bestScore = MIN_SCORE - 1;
for (let i = 0; i < pool.length; i += 1) {
const score = scoreMatch(role, pool[i].label);
if (score > bestScore) { bestScore = score; bestIndex = i; }
}
if (bestIndex < 0) return null;
return pool.splice(bestIndex, 1)[0];
});
}
/**
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
* `entries` is [{ ...whatever, name }].
*/
function bestLibraryMatch(pcoName, entries) {
const target = normalizeLabel(pcoName);
if (!target) return null;
const T = target.split(' ').filter(Boolean);
const scoreOf = (name) => {
const ln = normalizeLabel(name);
if (!ln) return 0;
if (ln === target) return 100; // exact full name
const L = ln.split(' ').filter(Boolean);
if (!L.length || !T.length) return 0;
// Align the library name against a leading run of the person's name, token by token.
// Every library token must equal the person's token in that position — except the LAST
// library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M"
// match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni".
let ok = true; let exact = 0; let usedPrefix = false;
for (let k = 0; k < L.length; k += 1) {
const lt = L[k]; const tt = T[k];
if (!tt) { ok = false; break; }
if (lt === tt) { exact += 1; continue; }
if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; }
ok = false; break;
}
if (ok) {
if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan"
if (exact === 1) return 90; // first name matches exactly ("alex")
if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous
}
// Otherwise: a library surname that appears anywhere in the person's name.
const last = L[L.length - 1];
if (last && last.length >= 3 && T.includes(last)) return 78;
return 0;
};
let best = null;
let bestScore = 0;
let tie = false;
for (const e of entries) {
const s = scoreOf(e.name);
if (s > bestScore) { bestScore = s; best = e; tie = false; }
else if (s === bestScore && s > 0) tie = true;
}
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
return best;
}
module.exports = {
normalizeTeamName,
isAllowedTeamName,
bestLibraryMatch,
normalizeLabel,
normalizeToken,
labelTokens,
slotRoleText,
numbersIn,
scoreMatch,
assignMembersToSlots,
MIN_SCORE,
};
+67
View File
@@ -0,0 +1,67 @@
'use strict';
const assert = require('assert');
const { assignMembersToSlots, isAllowedTeamName, slotRoleText, scoreMatch } = require('./matching');
const SLOTS = [
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4',
'[GUITAR, LEAD] MIX 5', '[BASS] MIX 6', '[VOX 5] MIX 7', '[PIANO]', '[DRUMS]',
'[KEYS 2]', '[CALL TO WORSHIP] MC2', 'STAGE MANAGER', '[MC] MC1', '[SPEAKER] HEADSET',
];
const M = (label) => ({ label, name: label });
const members = [
M('Vox 3'), M('Vox 2'), M('Vox 1'), M('Guitar / Lead'), M('Bass'), M('Piano'), M('Drums'), M('MC'), M('Keys 2'),
];
const out = assignMembersToSlots(SLOTS, members).map((m) => (m ? m.label : null));
// Numbered vocals land in the RIGHT slot (this is the swap bug we fixed).
assert.strictEqual(out[0], 'Vox 1', 'slot 0 → Vox 1');
assert.strictEqual(out[1], 'Vox 2', 'slot 1 → Vox 2');
assert.strictEqual(out[2], 'Vox 3', 'slot 2 → Vox 3');
assert.strictEqual(out[3], null, 'slot 3 (Vox 4) unfilled — nobody scheduled');
assert.strictEqual(out[4], 'Guitar / Lead', 'slot 4 → Guitar/Lead');
assert.strictEqual(out[5], 'Bass', 'slot 5 → Bass');
assert.strictEqual(out[6], null, 'slot 6 (Vox 5) unfilled');
assert.strictEqual(out[7], 'Piano', 'slot 7 → Piano');
assert.strictEqual(out[8], 'Drums', 'slot 8 → Drums');
assert.strictEqual(out[9], 'Keys 2', 'slot 9 → Keys 2');
assert.strictEqual(out[12], 'MC', 'slot 12 → MC');
// A different number never matches.
assert.strictEqual(scoreMatch(slotRoleText('[VOX 2] MIX 2'), 'Vox 3'), 0, 'Vox 3 must not match Vox 2 slot');
// Mic number no longer pollutes: Vox 7 must not grab "[VOX 5] MIX 7".
assert.strictEqual(scoreMatch(slotRoleText('[VOX 5] MIX 7'), 'Vox 7'), 0, 'Vox 7 must not match the MIX 7 slot');
// Team allow-list (singular stems match plurals/variants via substring).
assert.ok(isAllowedTeamName('Band', ['band', 'vocal']));
assert.ok(isAllowedTeamName('Vocals', ['band', 'vocal']));
assert.ok(isAllowedTeamName('Vocal Team', ['band', 'vocal']));
assert.ok(!isAllowedTeamName('Video', ['band', 'vocal', 'production']));
console.log('matching.test.js: all assertions passed ✓');
console.log('assignment:', JSON.stringify(out));
// --- bestLibraryMatch: folder-photo → PCO person by name ---
{
const { bestLibraryMatch } = require('./matching');
const name = (n) => (e) => (e ? e.name : null);
const lib = [
{ id: 'a', name: 'Alex' }, { id: 'g', name: 'AlexG' }, { id: 'bm', name: 'Brian M' },
{ id: 'c', name: 'Courtney' }, { id: 'gn', name: 'Gaitan' }, { id: 'wes', name: 'Wes' },
];
const pick = (n, list = lib) => { const e = bestLibraryMatch(n, list); return e ? e.name : null; };
assert.strictEqual(pick('Alex Gaitan'), 'Alex', 'first name matches "Alex"');
assert.strictEqual(pick('Brian Moniz'), 'Brian M', '"Brian M" matches "Brian Moniz"');
assert.strictEqual(pick('Courtney Maranda'), 'Courtney', 'first name matches');
assert.strictEqual(pick('Alexander Zinni'), null, '"Alex" must NOT grab "Alexander Zinni"');
assert.strictEqual(pick('Wes Carter'), 'Wes', 'first name matches "Wes"');
// surname-named photo
assert.strictEqual(pick('Someone Gaitan'), 'Gaitan', 'surname photo matches by last name');
// two people share a first name + only a first-name photo → ambiguous only if two matches tie;
// here a single "Alex" photo still maps to whoever's first name is exactly Alex.
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
// exact full name beats a first-name photo
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
// tie → no match (two identical-scoring photos)
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
console.log('bestLibraryMatch: all assertions passed ✓');
}
+154
View File
@@ -0,0 +1,154 @@
{
"id": "band-lineup",
"name": "Band Lineup",
"version": "1.11.0",
"proddash": ">=1.4.0",
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 4,
"h": 3
},
"defaultSize": {
"w": 8,
"h": 5
},
"configGroups": {
"Planning Center credentials": {
"collapsed": "whenSet",
"help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Reading photos and birthdays needs People access on the token. Saved secrets never reach browsers."
},
"Stage": {
"help": "One mic/pack label per line, in the order they sit on stage. Scheduled people are matched into these by their Planning Center position (e.g. a line '[VOX 2] MIX 2' takes whoever is scheduled on Vox 2). Blank lines are empty spots."
},
"Photo library": {
"help": "Paste a Google Drive folder share link (set the folder to 'Anyone with the link → Viewer'). Every image is imported (file name → person's name) and kept in sync — add or remove photos in Drive and the board follows. A local folder path also works. If Drive can't be read, the board falls back to Planning Center photos."
}
},
"configSchema": {
"appId": {
"type": "string",
"label": "Client ID",
"default": "",
"group": "Planning Center credentials"
},
"secret": {
"type": "password",
"label": "Secret",
"default": "",
"group": "Planning Center credentials"
},
"accessToken": {
"type": "password",
"label": "OAuth 2.0 access token (instead of Client ID + Secret)",
"default": "",
"group": "Planning Center credentials"
},
"serviceTypeId": {
"type": "select",
"label": "Service type",
"default": "",
"optionsRoute": "/service-types"
},
"planId": {
"type": "select",
"label": "Plan",
"default": "",
"options": [
{
"value": "",
"label": "Automatic — today's plan, else the next upcoming"
}
],
"optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId"
},
"teams": {
"type": "text",
"label": "Teams to include",
"default": "Band\nWorship\nVocal\nProduction\nCommunication",
"help": "One team name (or keyword) per line. A person on any matching Planning Center team is eligible for the board."
},
"slots": {
"type": "text",
"label": "Stage slots",
"default": "[LEADER, VOX 1, ACOUSTIC] MIX 1\n[VOX 2] MIX 2\n[VOX 3] MIX 3\n[VOX 4] MIX 4\n[GUITAR, LEAD] MIX 5\n[BASS] MIX 6\n[VOX 5] MIX 7\n[PIANO]\n[DRUMS]\n[KEYS 2]\n[CALL TO WORSHIP] MC2\nSTAGE MANAGER\n[MC] MC1\n[SPEAKER] HEADSET",
"group": "Stage"
},
"columns": {
"type": "number",
"label": "Columns",
"default": 7,
"group": "Stage",
"help": "Default slots per row (each screen can still adjust with the − / + buttons in its title bar)."
},
"photoFolder": {
"type": "string",
"label": "Google Drive folder link",
"default": "",
"group": "Photo library",
"help": "e.g. https://drive.google.com/drive/folders/… (a local folder path also works). Leave blank to use Planning Center photos only."
}
},
"instanceGroups": {
"Birthday": {
"toggle": "birthdays"
}
},
"instanceSchema": {
"showNames": {
"type": "boolean",
"label": "Names",
"default": true
},
"showLabels": {
"type": "boolean",
"label": "Mic labels",
"default": true
},
"parallax": {
"type": "boolean",
"label": "Parallax motion",
"default": true
},
"birthdays": {
"type": "switch",
"label": "Birthday confetti",
"default": true,
"group": "Birthday"
},
"birthdayIntensity": {
"type": "select",
"label": "Intensity",
"default": "subtle",
"group": "Birthday",
"showWhen": "birthdays",
"options": [
{
"value": "subtle",
"label": "Subtle"
},
{
"value": "full",
"label": "Full"
}
]
},
"birthdayTest": {
"type": "select",
"label": "Force on (test)",
"default": "",
"group": "Birthday",
"showWhen": "birthdays",
"options": [
{
"value": "",
"label": "Off"
}
],
"optionsRoute": "/people"
}
}
}
+764
View File
@@ -0,0 +1,764 @@
'use strict';
/**
* Band Lineup — server part.
*
* Owns the shared stage board and the uploaded-photo library, plus the one
* Planning Center connection. The board is server-side and global: whoever
* edits it (drag a library photo onto a slot, rename, clear) changes it for
* every Band Lineup tile on every screen, over SSE. Planning Center sync is an
* optional auto-fill — it only fills slots nobody has placed by hand. Browsers
* never talk to Planning Center directly.
*
* GET /state → { state } (board + library; instant paint)
* GET /stream → SSE `state` events (change-detected, heartbeat)
* GET /library → { people } (the drag-in photo library)
* GET /service-types → { options } (admin "Service type" select)
* GET /plans → { options } (admin "Plan" select)
* GET /people → { options } (gear "Force birthday" select)
* GET /photo/<file> → image bytes (library + cached PCO photos)
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
* POST /library/<id>/delete→ remove from the library and any slot
* POST /assign → ?slot=&personId= place a library person in a slot
* POST /move → ?from=&to= move or swap one slot's occupant
* POST /slot/<index>/name → ?name= rename the occupant (or make a name-only spot)
* POST /slot/<index>/clear → empty the slot
* POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
* POST /sync → run a Planning Center + photo-folder sync now
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const SERVICES = '/services/v2';
const PEOPLE = '/people/v2';
const REQUEST_TIMEOUT_MS = 12000;
const PHOTO_TIMEOUT_MS = 15000;
const MIN_POLL_S = 10;
const RETRY_MS = 15000;
const HEARTBEAT_MS = 15000;
const MODULE_ID = 'band-lineup';
const MAX_UPLOAD = 20 * 1024 * 1024;
let current = null; // set by init(), read by routes; rebuilt together on remount
/* ── data directory (library + board + photo cache live here, never in the repo) ── */
function resolveDataDir() {
const home = os.homedir();
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
const root = path.join(__dirname, '..', '..');
if (fromEnv) return path.resolve(root, fromEnv);
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ProdDash');
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
}
/* ── small helpers ─────────────────────────────────────────────────── */
function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
function nonEmptyLines(text) { return lines(text).filter(Boolean); }
function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
function birthdayMmdd(raw) {
const s = String(raw || '').trim();
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
if (full) return `${full[1]}-${full[2]}`;
return /^\d{2}-\d{2}$/.test(s) ? s : '';
}
function birthdayWeekSet(now = new Date()) {
const daysSinceWed = (now.getDay() - 3 + 7) % 7;
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
const set = new Set();
for (let i = 0; i < 7; i += 1) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
return set;
}
function extFromContentType(ct) {
const t = String(ct || '').toLowerCase();
if (t.includes('png')) return 'png';
if (t.includes('webp')) return 'webp';
if (t.includes('gif')) return 'gif';
return 'jpg';
}
function contentTypeForFile(file) {
const e = path.extname(file).toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'image/jpeg';
}
function json(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
function newId() { return crypto.randomBytes(6).toString('hex'); }
function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
/* ── Planning Center client ────────────────────────────────────────── */
function createPcoClient(config) {
const token = String(config.accessToken || '').trim();
const appId = String(config.appId || '').trim();
const secret = String(config.secret || '');
let auth = '';
if (token) auth = `Bearer ${token}`;
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
const configured = Boolean(auth);
async function get(target, params) {
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
let response;
try {
response = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
} catch (err) {
const e = new Error(`Planning Center unreachable: ${err?.message || err}`); e.network = true; throw e;
}
if (!response.ok) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
const text = await response.text();
return text ? JSON.parse(text) : {};
}
async function getAll(target, params) {
const data = []; const included = []; let next = null;
let query = { per_page: 100, ...(params || {}) }; let guard = 0;
do {
const body = next ? await get(next) : await get(target, query);
if (Array.isArray(body.data)) data.push(...body.data);
if (Array.isArray(body.included)) included.push(...body.included);
next = body?.links?.next || null; query = null; guard += 1;
} while (next && guard < 50);
return { data, included };
}
return { configured, get, getAll };
}
/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
const DRIVE_TIMEOUT_MS = 30000;
const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
function parseDriveFolderId(s) {
const str = String(s || '');
if (!/drive\.google\.com/i.test(str)) return '';
const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
return m ? m[1] : '';
}
/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
async function driveList(folderId) {
const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
const html = await resp.text();
const out = []; const seen = new Set();
const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+)</g;
let m;
while ((m = re.exec(html))) {
const id = m[1]; const name = m[2].trim();
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
}
return out;
}
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
async function driveFetchImage(fileId) {
const base = 'https://drive.usercontent.google.com/download';
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
let ct = String(resp.headers.get('content-type') || '');
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
// Large-file virus-scan interstitial: grab the confirm token and retry.
if (ct.includes('text/html')) {
const html = await resp.text();
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
if (conf) {
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
ct = String(resp.headers.get('content-type') || '');
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
}
}
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
const tct = String(th.headers.get('content-type') || '');
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
throw new Error('not an image');
}
/* ── the module ────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
const streams = new Set();
const dataDir = path.join(resolveDataDir(), MODULE_ID);
const photoDir = path.join(dataDir, 'photos');
const libraryPath = path.join(dataDir, 'library.json');
const boardPath = path.join(dataDir, 'board.json');
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
const columns = Math.max(1, Number(config.columns) || 7);
const serviceTypeId = String(config.serviceTypeId || '').trim();
const planIdCfg = String(config.planId || '').trim();
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
const settingsPath = path.join(dataDir, 'settings.json');
const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR';
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
// so its assignment persists in board.json across toggles.
const effectiveSlots = () => (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
const pco = createPcoClient(config);
// Persisted, shared editor state.
let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
let latest = clearedState();
let lastFrame = '';
let celebrateTimer = null;
let drivePollTimer = null;
let watcher = null;
let watchDebounce = null;
let stopped = false;
let syncing = false;
function clearedState() {
return {
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
reachable: false, lastError: '', lastFetched: 0, columns,
plan: null,
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
library: [], choir: choirOn,
};
}
function libraryList() {
return Object.entries(library)
.map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
function broadcast() {
if (!streams.size) return;
let frame; try { frame = JSON.stringify(latest); } catch { return; }
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
}
async function listPlans(stId) {
const [future, past] = await Promise.all([
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
]);
const rows = [...(future.data || []), ...(past.data || [])];
return rows.map((rec) => ({
id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
}));
}
function pickPlan(plans) {
if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
const todayY = localYmd(new Date());
const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
if (today) return today;
const now = Date.now();
const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
}
async function cachePcoPhoto(personId, url) {
if (!url) return '';
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
if (existing) return existing;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
if (!resp.ok) return '';
const buf = Buffer.from(await resp.arrayBuffer());
if (!buf.length) return '';
const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
fs.writeFileSync(path.join(photoDir, file), buf);
return file;
} catch { return ''; }
}
/**
* Import a folder of photos into the library and keep it in sync: new files are added
* (filename → name), changed files refreshed, and files removed from the folder dropped
* from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
*/
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
function scanFolder() {
if (!photoFolder) return false;
let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
const files = names.filter((n) => IMG_RE.test(n));
const present = new Set(files);
const byKey = new Map();
for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
let changed = false;
for (const f of files) {
let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
const id = byKey.get(f);
try {
if (id) {
const v = library[id];
if (v.mtime === mtime && v.file) continue; // unchanged
const dest = `lib-${id}.${extOf(f)}`;
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
} else {
const nid = newId();
const dest = `lib-${nid}.${extOf(f)}`;
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
changed = true;
}
} catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
}
for (const [id, v] of Object.entries(library)) {
if (v.folderKey && !present.has(v.folderKey)) {
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
delete library[id];
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
changed = true;
}
}
// De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
// (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
// where someone uploaded people by hand and then pointed at a folder of the same people.
const folderByName = new Map();
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
for (const [id, v] of Object.entries(library)) {
if (v.folderKey || !v.name) continue;
const keepId = folderByName.get(normName(v.name));
if (!keepId) continue;
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
delete library[id];
changed = true;
}
if (changed) { saveLibrary(); saveBoard(); }
return changed;
}
// Import a public Google Drive folder into the library (file name → name), keeping it in sync.
// Keyed by Drive file id (folderKey "drive:<id>"). On a list failure we keep the last-good library.
async function scanDrive(folderId) {
let files;
try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
files = files.filter((f) => IMG_RE.test(f.name));
const present = new Set(files.map((f) => `drive:${f.id}`));
const have = new Set();
for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
let changed = false;
for (const f of files) {
const key = `drive:${f.id}`;
if (have.has(key)) continue; // already imported (by Drive file id)
let img;
try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
const nid = newId();
const dest = `lib-${nid}.${img.ext}`;
try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
}
for (const [id, v] of Object.entries(library)) {
if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
delete library[id];
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
changed = true;
}
}
// Drive photo wins over a same-named hand-upload (repoint any slot to it).
const driveByName = new Map();
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
for (const [id, v] of Object.entries(library)) {
if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
const keepId = driveByName.get(normName(v.name));
if (!keepId) continue;
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
delete library[id]; changed = true;
}
if (changed) { saveLibrary(); saveBoard(); }
return changed;
}
// Import the configured photo source — a Drive folder link, or a local folder path.
async function syncPhotos() {
const fid = parseDriveFolderId(photoFolder);
if (fid) return scanDrive(fid);
if (photoFolder) return scanFolder();
return false;
}
function startWatch() {
if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
if (!photoFolder) return;
try {
watcher = fs.watch(photoFolder, { persistent: false }, () => {
clearTimeout(watchDebounce);
watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
});
log(`watching photo folder ${photoFolder}`);
} catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
}
function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
// Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
function prunePcoPhotos(keep) {
let files; try { files = fs.readdirSync(photoDir); } catch { return; }
for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
}
const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
// Resolve one board slot to a display person (or null). The board is the source of truth —
// there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
// entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
function resolveSlot(i, weekSet, keep) {
const e = board[String(i)];
if (!e) return null;
let name = '', file = '', birthday = '', manual = true, personId = '';
if (e.pco) {
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
manual = false; personId = e.pco.personId || '';
name = String(e.pco.name || '').trim();
file = e.pco.file || '';
birthday = e.pco.birthday || '';
} else if (e.personId) {
const lib = library[e.personId];
if (!lib) return null; // its library photo was deleted → slot is empty
personId = e.personId;
name = String(e.name || lib.name || '').trim();
file = lib.file || ''; birthday = lib.birthday || '';
} else if (e.name) {
name = String(e.name).trim();
} else return null;
if (file) keep.add(file);
return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
}
// Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
// and on a slow timer so a birthday week rolling over lights up on its own.
function renderBoard() {
const weekSet = birthdayWeekSet();
const keep = new Set();
const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
for (const v of Object.values(library)) if (v.file) keep.add(v.file);
prunePcoPhotos(keep);
latest = {
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
};
}
function rebuildLocal() { renderBoard(); broadcast(); }
// Pull the current plan from Planning Center and write matched people INTO the board. Fills only
// slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
async function syncNow() {
if (syncing) return;
syncing = true;
try {
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
if (pco.configured && serviceTypeId) {
const plan = pickPlan(await listPlans(serviceTypeId));
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
else {
const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
const members = []; const seen = new Set();
for (const m of tm.data || []) {
const status = String(m.attributes?.status || '').trim();
if (status === 'D' || /^declined$/i.test(status)) continue;
const personId = String(m.relationships?.person?.data?.id || '');
const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
const person = byKey.get(`Person:${personId}`);
members.push({
personId,
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
label: String(m.attributes?.team_position_name || '').trim(),
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
});
if (personId) seen.add(personId);
}
const bdays = new Map();
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
for (const m of members) m.birthday = bdays.get(m.personId) || '';
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
const eff = effectiveSlots();
const openIdx = []; const manualNames = new Set();
for (let i = 0; i < eff.length; i += 1) {
const e = board[String(i)];
if (e && (e.personId || e.name)) {
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
if (nm) manualNames.add(normName(nm));
} else openIdx.push(i);
}
const pool = members.filter((m) => !manualNames.has(normName(m.name)));
const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k];
if (m) {
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
} else if (board[String(i)] && board[String(i)].pco) {
delete board[String(i)]; // was synced, no longer on the plan
}
}
saveBoard();
latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
} // end else (plan found)
} // end if (Planning Center configured)
} catch (err) {
latest.reachable = false; latest.lastError = err?.message || String(err);
log(`sync failed: ${err?.message || err}`);
} finally {
syncing = false;
renderBoard(); broadcast();
}
}
const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
heartbeat.unref?.();
// Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
celebrateTimer.unref?.();
// A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
// in Drive show up without a manual Sync.
drivePollTimer = setInterval(() => {
if (stopped || !parseDriveFolderId(photoFolder)) return;
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
}, 5 * 60 * 1000);
drivePollTimer.unref?.();
if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
rebuildLocal(); // paint the saved board immediately
startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
current = {
streams, getLatest: () => latest, pco, listPlans, photoDir,
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn,
};
return {
stop() {
stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
if (watchDebounce) clearTimeout(watchDebounce);
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
streams.clear(); if (current && current.streams === streams) current = null;
},
health() {
if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
if (latest.lastError) return { status: 'error', message: latest.lastError };
if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
return { status: 'ok', message: 'Ready — press Sync to load the plan' };
},
};
},
routes({ log }) {
// Read an image request body (raw bytes) with a size cap.
function readBody(req, cb) {
const chunks = []; let size = 0; let bad = false;
req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
req.on('error', () => cb(null));
}
return {
'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : null }),
'GET /stream': (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
res.write('retry: 3000\n\n');
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getLatest() : null)}\n\n`);
if (!current) return void res.end();
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
req.on('close', () => current && current.streams.delete(res));
},
'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
// Update a library entry: ?name=&birthday= and/or a replacement image body. /library/<id>/delete removes it.
'POST /library/*': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
const del = /\/delete$/.test(rest);
const id = rest.replace(/\/delete$/, '').trim();
const entry = current.library[id];
if (!entry) return json(res, 404, { error: 'No such library photo.' });
if (del) {
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
delete current.library[id];
for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
return json(res, 200, { ok: true });
}
const q = new URLSearchParams(req.search || '');
readBody(req, (buf) => {
if (q.has('name')) entry.name = String(q.get('name') || '').trim();
if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
if (buf && buf.length) {
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
}
current.saveLibrary(); current.rebuildLocal();
json(res, 200, { ok: true });
});
},
// Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
'POST /choir': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const p = new URLSearchParams(req.search || '');
const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
current.setChoir(on);
json(res, 200, { ok: true, choir: on });
},
// Move (or swap) one slot's occupant to another slot: ?from=<i>&to=<j>.
'POST /move': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const p = new URLSearchParams(req.search || '');
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
const a = current.board[from]; const b = current.board[to];
if (a === undefined) delete current.board[to]; else current.board[to] = a;
if (b === undefined) delete current.board[from]; else current.board[from] = b;
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
},
// Place a library person in a slot: ?slot=<index>&personId=<id> (empty personId clears the slot).
'POST /assign': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const q = new URLSearchParams(req.search || '');
const slot = String(q.get('slot') || '').trim();
const personId = String(q.get('personId') || '').trim();
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
if (personId) current.board[slot] = { personId, name: '' };
else delete current.board[slot];
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
},
// Rename a slot's occupant, or make a name-only spot (no photo). /slot/<index>/clear empties it.
'POST /slot/*': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
const clearing = /\/clear$/.test(rest);
const index = rest.replace(/\/(clear|name)$/, '').trim();
if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
const q = new URLSearchParams(req.search || '');
const name = String(q.get('name') || '').trim();
const existing = current.board[index] || { personId: '', name: '' };
existing.name = name;
if (!existing.personId && !name) delete current.board[index];
else current.board[index] = existing;
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
},
// Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
// runs on its own timer; this just does it now.
'POST /sync': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
json(res, 200, { ok: true });
},
'GET /people': (req, res) => {
const state = current ? current.getLatest() : null;
const options = [{ value: '', label: 'Off' }];
for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
json(res, 200, { options });
},
'GET /photo/*': (req, res) => {
if (!current) return void res.writeHead(404).end();
const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
if (!name || name.includes('..')) return void res.writeHead(400).end();
fs.readFile(path.join(current.photoDir, name), (err, buf) => {
if (err) return void res.writeHead(404).end();
res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
res.end(buf);
});
},
'GET /service-types': async (req, res) => {
if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
try {
const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
const folders = new Map();
for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
const pathOf = (parentId) => { const names = []; let id = parentId; const seen = new Set(); while (id && folders.has(id) && !seen.has(id)) { seen.add(id); names.unshift(folders.get(id).name); id = folders.get(id).parentId; } return names.join(' › '); };
const options = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
json(res, 200, { options });
} catch (err) { log(`service type list failed: ${err?.message || err}`); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); }
},
'GET /plans': async (req, res) => {
if (!current) return json(res, 503, { options: [] });
const params = new URLSearchParams(req.search || '');
const stId = String(params.get('serviceTypeId') || '').trim();
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
if (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
try {
const plans = await current.listPlans(stId); const now = Date.now();
const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
} catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
},
};
},
};
+232
View File
@@ -0,0 +1,232 @@
/* Band Lineup — scoped to the tile, themed only through the shell's variables. */
[data-module="band-lineup"] .bl-board {
height: 100%;
overflow: hidden;
display: grid;
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
grid-template-rows: repeat(var(--bl-rows, 2), minmax(0, 1fr));
gap: 6px;
padding: 6px;
}
[data-module="band-lineup"] .bl-message {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted);
padding: 16px;
font-size: 0.95em;
}
[data-module="band-lineup"] .bl-slot {
min-width: 0;
min-height: 0;
}
[data-module="band-lineup"] .bl-tile {
display: flex;
flex-direction: column;
border-radius: 10px;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
height: 100%;
position: relative;
}
[data-module="band-lineup"] .bl-photo {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: color-mix(in srgb, var(--bg) 70%, black);
}
[data-module="band-lineup"] .bl-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
display: block;
transition: transform 0.12s ease-out;
will-change: transform;
}
[data-module="band-lineup"] .bl-initials {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.4em;
font-weight: 800;
color: color-mix(in srgb, var(--muted) 70%, transparent);
letter-spacing: 0.04em;
}
[data-module="band-lineup"] .bl-name {
position: absolute;
top: 6px;
left: 6px;
right: 6px;
max-width: calc(100% - 12px);
padding: 3px 7px;
border-radius: 6px;
background: color-mix(in srgb, var(--bg) 82%, transparent);
color: var(--text);
font-size: 0.74em;
font-weight: 700;
line-height: 1.12;
z-index: 5;
/* Show the whole name — wrap onto more lines rather than clipping with an ellipsis. */
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
[data-module="band-lineup"] .bl-label {
padding: 5px 6px;
font-size: 0.72em;
font-weight: 600;
text-align: center;
color: var(--text);
background: color-mix(in srgb, var(--bg) 60%, transparent);
border-top: 1px solid var(--border);
min-height: 1.6em;
display: flex;
align-items: center;
justify-content: center;
word-break: break-word;
}
[data-module="band-lineup"] .bl-empty {
border-style: dashed;
opacity: 0.55;
}
[data-module="band-lineup"] .bl-empty .bl-label {
background: transparent;
border-top: none;
margin: auto;
color: var(--muted);
}
/* ---- Birthday effects (confetti canvas + gold glow ring) ---- */
[data-module="band-lineup"] .bl-confetti {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
}
[data-module="band-lineup"] .bl-birthday-subtle {
animation: bl-glow-subtle 3.4s ease-in-out infinite;
}
[data-module="band-lineup"] .bl-birthday-full {
animation: bl-glow-full 2.2s ease-in-out infinite;
}
@keyframes bl-glow-subtle {
0%, 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 14px color-mix(in srgb, var(--accent) 20%, transparent); }
50% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 20px color-mix(in srgb, var(--accent) 34%, transparent); }
}
@keyframes bl-glow-full {
0%, 100% { box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 80%, transparent), 0 0 20px color-mix(in srgb, var(--accent) 42%, transparent); }
50% { box-shadow: inset 0 0 0 3px var(--accent), 0 0 34px color-mix(in srgb, var(--accent) 66%, transparent); }
}
/* ---- Editor (Edit button): board drop-targets + uploaded-photo library ---- */
[data-module="band-lineup"] .bl-btn {
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
border-radius: 6px;
padding: 5px 11px;
font: inherit;
font-size: 0.82em;
cursor: pointer;
}
[data-module="band-lineup"] .bl-btn.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
[data-module="band-lineup"] .bl-btn:hover { border-color: var(--accent); }
[data-module="band-lineup"] .bl-editor {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
background: var(--bg);
}
[data-module="band-lineup"] .bl-editor-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
[data-module="band-lineup"] .bl-editor-bar b { color: var(--text); }
[data-module="band-lineup"] .bl-editor-spacer { flex: 1; }
[data-module="band-lineup"] .bl-folder { display: flex; align-items: center; gap: 6px; min-width: 0; }
[data-module="band-lineup"] .bl-folder-path { font-size: 0.72em; color: var(--muted); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-module="band-lineup"] .bl-editor-body { flex: 1; min-height: 0; overflow: auto; padding: 10px; }
[data-module="band-lineup"] .bl-edit-board {
display: grid;
grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
gap: 6px;
margin-bottom: 12px;
}
[data-module="band-lineup"] .bl-edit-slot {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--panel);
display: flex;
flex-direction: column;
}
[data-module="band-lineup"] .bl-edit-slot.bl-drop-over { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
[data-module="band-lineup"] .bl-edit-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
[data-module="band-lineup"] .bl-edit-photo .bl-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
[data-module="band-lineup"] .bl-edit-drop {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 1.6em; border: 1px dashed var(--border);
}
[data-module="band-lineup"] .bl-edit-namerow { display: flex; align-items: center; gap: 2px; padding: 2px; }
[data-module="band-lineup"] .bl-edit-name {
flex: 1; min-width: 0; text-align: left; background: transparent; border: 0;
color: var(--text); font: inherit; font-size: 0.72em; font-weight: 600; padding: 3px 4px;
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px;
}
[data-module="band-lineup"] .bl-edit-name:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
[data-module="band-lineup"] .bl-edit-clear {
flex: 0 0 auto; width: 20px; height: 20px; border: 0; border-radius: 4px;
background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); cursor: pointer; font-size: 0.9em; line-height: 1;
}
[data-module="band-lineup"] .bl-edit-label { padding: 3px 5px 5px; font-size: 0.64em; color: var(--muted); text-align: center; border-top: 1px solid var(--border); word-break: break-word; }
[data-module="band-lineup"] .bl-library {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
gap: 6px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
[data-module="band-lineup"] .bl-lib-empty { grid-column: 1 / -1; color: var(--muted); font-size: 0.85em; padding: 10px 2px; }
[data-module="band-lineup"] .bl-lib-item {
position: relative; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
background: var(--panel); cursor: grab; display: flex; flex-direction: column;
}
[data-module="band-lineup"] .bl-lib-item:active { cursor: grabbing; }
[data-module="band-lineup"] .bl-lib-selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
[data-module="band-lineup"] .bl-lib-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
[data-module="band-lineup"] .bl-lib-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; pointer-events: none; }
[data-module="band-lineup"] .bl-lib-name {
border: 0; background: transparent; color: var(--text); font: inherit; font-size: 0.68em; font-weight: 600;
padding: 3px 4px; text-align: center; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-module="band-lineup"] .bl-lib-del {
position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; border: 0; border-radius: 4px;
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
}
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
+177 -3
View File
@@ -2,10 +2,12 @@
/** /**
* Title matching for the PCO Plan module — the one answer to "is this * Title matching for the PCO Plan module — the one answer to "is this
* ProPresenter document that Planning Center thing?", used twice: * ProPresenter document that Planning Center thing?", used three ways:
* *
* - mapping the document ProPresenter has live to a plan item (the * - mapping the document ProPresenter has live to a plan item (the
* highlight and the item runtimes), and * highlight and the item runtimes) — placeDocument(), below,
* - recognising the message document, which is named after the sermon
* and never after its plan item — the message rule, below, and
* - recognising the documents an admin picked as the service start / end * - recognising the documents an admin picked as the service start / end
* cues (see service-timing.js). * cues (see service-timing.js).
* *
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD; return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
} }
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue }; /* ── the message rule ──────────────────────────────────────────────────
A sermon's ProPresenter document is named after the sermon, never after
the plan item that stands for it ("Message", "Resi Message"), so no name
comparison can place it — and the previous song stayed "current" through
a whole message. What the document's name does carry is the plan's own
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
date, its series title and its title. A live document that reads like
the plan is the plan's message, and lands on the plan's message item,
however that item happens to be titled. */
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
/** Undated, the name must read like "Series Title Part #" at least this closely… */
const MESSAGE_THRESHOLD = 0.75;
/** …dated with this plan's date, it need only resemble the series or the title. */
const DATED_MESSAGE_THRESHOLD = 0.6;
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
function leadingDateCode(raw) {
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
if (!m) return null;
const code = m[1];
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
const mo = Number(code.slice(-4, -2));
const d = Number(code.slice(-2));
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return { y, m: mo, d };
}
/** The local calendar day of an epoch-ms instant, in the same shape. */
function dayOf(ms) {
const t = new Date(Number(ms));
if (!Number.isFinite(t.getTime())) return null;
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
}
function sameDay(a, b) {
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
}
/**
* Does this live document read like the plan itself — "260927 Where is Your
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
* 27 Sept 2026? Returns { score, dated } or null.
*
* A date code naming another day is a firm no: last week's sermon is not
* this plan's message. A date code naming the plan's day is strong evidence,
* so the rest need only resemble the series or the title. Without a date
* the name must read like series + title together — and carry the title
* when the plan has both, so a series bumper ("Where is Your Faith?") is
* not taken for the sermon.
*/
function messageDocumentScore(name, plan) {
const series = String(plan?.seriesTitle || '').trim();
const title = String(plan?.title || '').trim();
if (!series && !title) return null;
const code = leadingDateCode(name);
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
if (code && planDay && !sameDay(code, planDay)) return null;
const dated = Boolean(code && planDay);
const full = [series, title].filter(Boolean).join(' ');
const fullScore = similarity(name, full);
if (dated) {
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
}
if (fullScore < MESSAGE_THRESHOLD) return null;
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
return { score: fullScore, dated: false };
}
/**
* The plan item that stands for the message: an item titled with the word
* ("Message", "Resi Message" — a bare "Message" first), else the first item
* under a header so titled (a "Sermon" section whose one item is the sermon's
* own title). Null when the plan has no such slot.
*/
function messageItemOf(items) {
let best = null;
let inSection = false;
for (const it of Array.isArray(items) ? items : []) {
if (!it) continue;
const title = String(it.title || '');
if (it.itemType === 'header') {
inSection = MESSAGE_WORDS.test(title);
continue;
}
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
if (rank === 3) break;
}
return best ? best.item : null;
}
/**
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
* builds a Planning Center playlist that way — every plan item, headers
* included, each flagged as PCO — until an operator adds something of their
* own (a song tag, say), which shifts every position after it. Same length,
* headers in the same places and every item PCO-flagged is the test.
*/
function playlistMirrorsPlan(ppItems, items) {
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
}
/** Best plan item by title, the ones ahead of the current item winning ties. */
function bestByName(name, items, all, ctx) {
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
const hint = ctx?.hint;
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
let best = null;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last).
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
const atPosition = Boolean(wanted && item.id === wanted.id);
const candidate = { item, score, dist, atPosition };
if (!best || score > best.score + 0.001) {
best = candidate;
continue;
}
if (Math.abs(score - best.score) > 0.001) continue;
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
}
return best ? { item: best.item, score: best.score, via: 'name' } : null;
}
/**
* Where a live ProPresenter document sits in the plan: { item, score, via }
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
* is { index, mirror } — the document's position in the ProPresenter
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
*
* By name first: the item (or song) title most like the document's name,
* the ones ahead of the current item winning ties — a near miss lands on the
* right row. Then the message rule: a document that reads like the plan
* itself lands on the plan's message item. It overrides a name hit that is
* anything short of identical — a song called "Faith" must not catch
* "260927 Where is Your Faith? - Part 6" — outright when the document is
* dated with the plan's day, and when it reads better otherwise. Position
* is never a match on its own: it only breaks a tie between equally good
* names, and only while the playlist mirrors the plan.
*/
function placeDocument(name, ctx) {
const all = Array.isArray(ctx?.items) ? ctx.items : [];
const items = all.filter((it) => it && it.itemType !== 'header');
if (!items.length) return null;
const byName = bestByName(name, items, all, ctx);
const asMessage = messageDocumentScore(name, ctx?.plan);
const messageItem = asMessage ? messageItemOf(all) : null;
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
}
return byName;
}
module.exports = {
MATCH_THRESHOLD,
TRIGGER_THRESHOLD,
MESSAGE_THRESHOLD,
cleanTitle,
normalize,
similarity,
isCue,
leadingDateCode,
messageDocumentScore,
messageItemOf,
playlistMirrorsPlan,
placeDocument,
};
+280
View File
@@ -0,0 +1,280 @@
'use strict';
/**
* Unit tests for the PCO Plan module's document matching — placing what
* ProPresenter has live onto a plan item, and the message rule.
*
* node modules/pco-plan/matching.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. The two
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
* as they stood on 22 Sept, so the expectations read like a Sunday would.
*/
const assert = require('assert');
const {
MESSAGE_THRESHOLD,
leadingDateCode,
messageDocumentScore,
messageItemOf,
playlistMirrorsPlan,
placeDocument,
} = require('./matching');
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
throw err;
}
}
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
// North Attleboro — the message is an item called "Message", linked in
// ProPresenter to the bumper video; the sermon slides are their own document.
const NA = [
header('h0', 'Doors Open'),
item('1', 'Preservice'),
item('2', '5 Minute Countdown', 'media'),
item('3', 'MC Announcements', 'media'),
header('h4', 'Worship'),
item('5', 'Call To Worship', 'media'),
item('6', 'Alleluia', 'song', 'Alleluia'),
item('7', 'What a God', 'song', 'What a God'),
item('8', 'Baptisms', 'media'),
item('9', 'Promised Land', 'song', 'Promised Land'),
item('10', 'Be Praised', 'song', 'Be Praised'),
header('h11', 'Message'),
item('12', 'Message'),
item('13', 'Standing Salvation Moment', 'media'),
header('h14', 'Closing'),
item('15', 'Postservice', 'song', '-Postservice'),
];
const NA_PP = [
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
pp('Closing', 'header'), pp('Postservice'),
];
// Apollo Beach — the message is the media item "Resi Message".
const AB = [
item('0', 'Pre Service Meeting', 'media'),
header('h1', 'Doors Open'),
item('2', 'Pre Service', 'media'),
item('3', '5 Minute Countdown', 'media'),
item('4', 'MC Announcements', 'media'),
header('h5', 'Worship'),
item('6', 'Call To Worship', 'media'),
item('7', 'Alleluia', 'song', 'Alleluia'),
item('8', 'What a God', 'song', 'What a God'),
item('9', 'The Blood', 'song', 'The Blood'),
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
header('h11', 'Message'),
item('12', 'Resi Message', 'media'),
item('13', 'Standing Salvation Moment', 'media'),
header('h14', 'Closing'),
item('15', 'Post Service', 'media'),
];
const SERMON = '260927 Where is Your Faith? - Part 6';
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
const titleOf = (hit) => (hit ? hit.item.title : null);
console.log('date codes');
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
assert.strictEqual(leadingDateCode('261340 no such month'), null);
assert.strictEqual(leadingDateCode('Alleluia'), null);
assert.strictEqual(leadingDateCode(''), null);
});
console.log('the message rule');
test('a document named like the plan, dated with its day, is the message', () => {
const hit = messageDocumentScore(SERMON, PLAN);
assert.ok(hit, 'recognised');
assert.strictEqual(hit.dated, true);
assert.strictEqual(hit.score, 1);
});
test("last week's document is not this plan's message", () => {
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
});
test('undated, series and part together still read as the message', () => {
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
assert.ok(plain && plain.dated === false && plain.score === 1);
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
});
test('a series bumper is not the sermon', () => {
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
});
test("dated with the plan's day, the part alone is enough", () => {
const hit = messageDocumentScore('260927 Part 6', PLAN);
assert.ok(hit && hit.dated);
});
test('a dated document about something else is not the message', () => {
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
});
test('songs are not the message', () => {
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
});
test('a plan with neither series nor title has no message document', () => {
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
assert.strictEqual(messageDocumentScore(SERMON, null), null);
});
test('a plan without a date still recognises its own name', () => {
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
assert.ok(hit && hit.dated === false && hit.score === 1);
});
console.log('the message item');
test('an item called Message is the message item; so is Resi Message', () => {
assert.strictEqual(messageItemOf(NA).id, '12');
assert.strictEqual(messageItemOf(AB).id, '12');
});
test('a bare "Message" beats an item that only contains the word', () => {
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
assert.strictEqual(messageItemOf(withBumper).id, '12');
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
});
test('under a Sermon header, the first item is the message whatever it is called', () => {
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
assert.strictEqual(messageItemOf(plan).id, '2');
});
test('a plan without a message slot has none', () => {
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
assert.strictEqual(messageItemOf([]), null);
assert.strictEqual(messageItemOf(null), null);
});
console.log('the playlist mirror');
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
});
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
const shifted = [...NA_PP];
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
assert.strictEqual(playlistMirrorsPlan([], []), false);
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
});
console.log('placing what is live');
test('songs and media land by name, tags and all', () => {
const god = place('What a God - [ Full ]', NA);
assert.strictEqual(titleOf(god), 'What a God');
assert.strictEqual(god.via, 'name');
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
});
test('the sermon document lands on the message item at both campuses', () => {
const na = place(SERMON, NA);
assert.strictEqual(titleOf(na), 'Message');
assert.strictEqual(na.via, 'message document, dated');
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
});
test('the bumper lands on the message item too, by name', () => {
const hit = place('Message Bumper', NA);
assert.strictEqual(titleOf(hit), 'Message');
assert.strictEqual(hit.via, 'name');
});
test('a song called Faith does not catch the sermon', () => {
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
});
test('an item titled exactly like the document wins over the message slot', () => {
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
assert.strictEqual(place(SERMON, titled).item.id, 's');
});
test('a plan without a message slot leaves the sermon document unplaced', () => {
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
assert.strictEqual(place(SERMON, noSlot), null);
});
test("an unplanned document, or last week's sermon, lands nowhere", () => {
assert.strictEqual(place('Offering Slide', NA), null);
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
});
test('nothing to place on an empty plan', () => {
assert.strictEqual(place(SERMON, []), null);
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
});
// The same song twice: a reprise after "What a God".
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
test('a tie between equal names goes to the one ahead of the current item', () => {
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
});
test('position breaks a tie only while the playlist mirrors the plan', () => {
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
assert.strictEqual(mirror, true);
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
});
test('position never places a document on its own', () => {
const mirror = playlistMirrorsPlan(NA_PP, NA);
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
});
console.log(`\n${passed} tests passed`);
+245 -49
View File
@@ -1,15 +1,23 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.4.0", "version": "1.6.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services", "description": "Order of service from Planning Center Services",
"provides": ["timers"], "provides": [
"timers"
],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 3, "h": 3 }, "minSize": {
"defaultSize": { "w": 5, "h": 6 }, "w": 3,
"h": 3
},
"defaultSize": {
"w": 5,
"h": 6
},
"configGroups": { "configGroups": {
"Planning Center credentials": { "Planning Center credentials": {
"collapsed": "whenSet", "collapsed": "whenSet",
@@ -48,7 +56,12 @@
"type": "select", "type": "select",
"label": "Plan", "label": "Plan",
"default": "", "default": "",
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }], "options": [
{
"value": "",
"label": "Automatic — today's plan, else the next upcoming"
}
],
"optionsRoute": "/plans", "optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId" "optionsDependsOn": "serviceTypeId"
}, },
@@ -82,8 +95,14 @@
"group": "Service timing", "group": "Service timing",
"showWhen": "useDocumentTriggers", "showWhen": "useDocumentTriggers",
"options": [ "options": [
{ "value": "schedule", "label": "Scheduled service time" }, {
{ "value": "document", "label": "ProPresenter document" } "value": "schedule",
"label": "Scheduled service time"
},
{
"value": "document",
"label": "ProPresenter document"
}
] ]
}, },
"serviceStartDocument": { "serviceStartDocument": {
@@ -124,37 +143,94 @@
"Item notes": { "Item notes": {
"toggle": "showNotes" "toggle": "showNotes"
}, },
"Show / hide": { "Show": {
"collapsed": true, "collapsed": true,
"columns": 3, "columns": 2
"help": "Switch off anything this tile should not show."
} }
}, },
"instanceSchema": { "instanceSchema": {
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true }, "autoScroll": {
"type": "boolean",
"label": "Auto-scroll",
"default": true
},
"headerTemplate": { "headerTemplate": {
"type": "template", "type": "template",
"label": "Top bar text", "label": "Top bar text",
"default": "{series} • {part} | {serviceType} • {date}", "default": "{series} • {part} | {serviceType} • {date}",
"group": "Top bar", "group": "Top bar",
"variables": [ "variables": [
{ "value": "{series}", "label": "Series" }, {
{ "value": "{part}", "label": "Part / plan title" }, "value": "{series}",
{ "value": "{serviceType}", "label": "Service type" }, "label": "Series"
{ "value": "{date}", "label": "Date" }, },
{ "value": "{shortDate}", "label": "Short date" }, {
{ "value": "{time}", "label": "Service time" }, "value": "{part}",
{ "value": "{length}", "label": "Planned length" }, "label": "Part / plan title"
{ "value": "{items}", "label": "Item count" } },
{
"value": "{serviceType}",
"label": "Service type"
},
{
"value": "{date}",
"label": "Date"
},
{
"value": "{shortDate}",
"label": "Short date"
},
{
"value": "{time}",
"label": "Service time"
},
{
"value": "{length}",
"label": "Planned length"
},
{
"value": "{items}",
"label": "Item count"
}
], ],
"help": "Use • between items and | to start a second line." "help": "Use • between items and | to start a second line."
}, },
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" }, "keyColor": {
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" }, "type": "color",
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" }, "label": "Song key",
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" }, "default": "#4ea1ff",
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" }, "group": "Colors"
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" }, },
"headerColor": {
"type": "color",
"label": "Section headers",
"default": "#2ee59a",
"group": "Colors"
},
"currentColor": {
"type": "color",
"label": "Current item",
"default": "#2ee59a",
"group": "Colors"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"tintByType": {
"type": "boolean",
"label": "Tint by type",
"default": false,
"group": "Colors"
},
"showNotes": {
"type": "switch",
"label": "Item notes",
"default": true,
"group": "Item notes"
},
"noteCategories": { "noteCategories": {
"type": "multiselect", "type": "multiselect",
"label": "Categories", "label": "Categories",
@@ -163,29 +239,149 @@
"columns": 3, "columns": 3,
"allByDefault": true "allByDefault": true
}, },
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" }, "showTopBar": {
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" }, "type": "boolean",
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" }, "label": "Top bar",
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" }, "default": true,
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" }, "group": "Show"
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" }, },
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" }, "showSeries": {
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" }, "type": "boolean",
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" }, "label": "Series name",
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" }, "default": true,
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" }, "group": "Show"
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" }, },
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" }, "showPart": {
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" }, "type": "boolean",
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" }, "label": "Part / title",
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" }, "default": true,
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" }, "group": "Show"
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" }, },
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" }, "showServiceType": {
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" }, "type": "boolean",
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" }, "label": "Service type",
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" }, "default": true,
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" }, "group": "Show"
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" } },
"showDate": {
"type": "boolean",
"label": "Plan date",
"default": true,
"group": "Show"
},
"showServiceTime": {
"type": "boolean",
"label": "Service time",
"default": true,
"group": "Show"
},
"showTotalLength": {
"type": "boolean",
"label": "Total length",
"default": true,
"group": "Show"
},
"showRunningClock": {
"type": "boolean",
"label": "Running clock",
"default": true,
"group": "Show"
},
"showCountdown": {
"type": "boolean",
"label": "Countdown",
"default": true,
"group": "Show"
},
"showHeaders": {
"type": "boolean",
"label": "Section headers",
"default": true,
"group": "Show"
},
"showItemNumbers": {
"type": "boolean",
"label": "Item numbers",
"default": false,
"group": "Show"
},
"showStartTimes": {
"type": "boolean",
"label": "Start times",
"default": true,
"group": "Show"
},
"showLength": {
"type": "boolean",
"label": "Item length",
"default": true,
"group": "Show"
},
"showItemType": {
"type": "boolean",
"label": "Type badge",
"default": true,
"group": "Show"
},
"showKey": {
"type": "boolean",
"label": "Song key",
"default": true,
"group": "Show"
},
"showArrangement": {
"type": "boolean",
"label": "Arrangement",
"default": false,
"group": "Show"
},
"showSongAuthor": {
"type": "boolean",
"label": "Author / CCLI",
"default": false,
"group": "Show"
},
"showDescription": {
"type": "boolean",
"label": "Item description",
"default": true,
"group": "Show"
},
"showPreService": {
"type": "boolean",
"label": "Pre-service",
"default": true,
"group": "Show"
},
"showPostService": {
"type": "boolean",
"label": "Post-service",
"default": true,
"group": "Show"
},
"showNowIndicator": {
"type": "boolean",
"label": "Now indicator",
"default": true,
"group": "Show"
},
"showItemRuntime": {
"type": "boolean",
"label": "Item runtime",
"default": true,
"group": "Show"
},
"dimCompleted": {
"type": "boolean",
"label": "Dim completed",
"default": true,
"group": "Show"
},
"showLiveFooter": {
"type": "boolean",
"label": "Match line",
"default": true,
"group": "Show"
}
} }
} }
+69 -34
View File
@@ -33,8 +33,12 @@
* Following ProPresenter: instead of talking to ProPresenter a second time, * Following ProPresenter: instead of talking to ProPresenter a second time,
* this module reads the propresenter-now-next module's own SSE feed over * this module reads the propresenter-now-next module's own SSE feed over
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
* matches the live presentation name to a plan item. Item start/end * places the live document on a plan item (matching.js placeDocument): by
* timestamps are tracked here, once, so every tile agrees on the runtime. * name, or — for the sermon, whose document is named after the sermon and
* never after its plan item — by reading like the plan itself ("YYMMDD
* Series Title - Part #"), which lands it on the plan's message item. Item
* start/end timestamps are tracked here, once, so every tile agrees on the
* runtime.
* *
* Service timing: a plan's services (9:00, 11:00…) take turns owning the * Service timing: a plan's services (9:00, 11:00…) take turns owning the
* service clock — the rules live in service-timing.js. A service starts at * service clock — the rules live in service-timing.js. A service starts at
@@ -52,7 +56,7 @@ const http = require('http');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { MATCH_THRESHOLD, similarity } = require('./matching'); const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
const { const {
MINUTE, MINUTE,
serviceTimes, serviceTimes,
@@ -434,6 +438,8 @@ module.exports = {
let inFlight = null; let inFlight = null;
let lastFrame = ''; let lastFrame = '';
let lastTimersSig = ''; let lastTimersSig = '';
let lastMessageItemId = null;
let lastMatchError = '';
/* — broadcasting — */ /* — broadcasting — */
@@ -582,6 +588,7 @@ module.exports = {
} }
state.plan = plan; state.plan = plan;
state.items = items; state.items = items;
noteMessageItem(items);
state.reachable = true; state.reachable = true;
state.lastError = ''; state.lastError = '';
state.lastFetched = Date.now(); state.lastFetched = Date.now();
@@ -596,6 +603,17 @@ module.exports = {
syncServiceSafe(Date.now(), null); syncServiceSafe(Date.now(), null);
} }
/** Say, once per plan, which item a document named like the plan will land on. */
function noteMessageItem(items) {
const msg = messageItemOf(items);
const id = msg ? msg.id : '';
if (id === lastMessageItemId) return;
lastMessageItemId = id;
log(msg
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
: 'no message item in this plan — a document named like the plan has nowhere to land');
}
/* — item note categories (a tile's "Item notes" → Categories switches) — */ /* — item note categories (a tile's "Item notes" → Categories switches) — */
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
@@ -996,36 +1014,29 @@ module.exports = {
}; };
lastLiveName = keep.activeName || ''; lastLiveName = keep.activeName || '';
if (follow && keep.activeName) { if (follow && keep.activeName) {
const hit = matchItem(keep.activeName); const hit = matchItem(keep.activeName, hintFrom(lastView));
state.live.matched = Boolean(hit); state.live.matched = Boolean(hit);
if (hit) { if (hit) {
state.live.currentScore = hit.score; state.live.currentScore = hit.score;
startItem(hit.item.id); startItem(hit.item.id, keep.activeName, hit);
} }
} }
} }
/** Best plan item for a live presentation name, preferring the ones ahead of us. */ /**
function matchItem(name) { * The plan item a live document stands for — { item, score, via } or
const items = state.items.filter((it) => it.itemType !== 'header'); * null. The rules live in matching.js (placeDocument): by name, then the
if (!items.length) return null; * message rule for a document named like the plan itself; the playlist
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId); * position only breaks ties, and only while the playlist mirrors the plan.
let best = null; */
for (let i = 0; i < items.length; i += 1) { function matchItem(name, hint) {
const item = items[i]; return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0); }
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last). /** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length; function hintFrom(view) {
if ( const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
!best return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
|| score > best.score + 0.001
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
) {
best = { item, score, dist };
}
}
return best;
} }
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */ /** Remember a document ProPresenter put live, most recent first, for the admin selects. */
@@ -1077,6 +1088,7 @@ module.exports = {
function applyLiveView(view) { function applyLiveView(view) {
lastView = view && typeof view === 'object' ? view : null; lastView = view && typeof view === 'object' ? view : null;
const live = state.live; const live = state.live;
if (!live.available) log('following the ProPresenter module feed');
live.available = true; live.available = true;
live.reason = ''; live.reason = '';
live.ppEnabled = Boolean(view?.enabled); live.ppEnabled = Boolean(view?.enabled);
@@ -1095,23 +1107,40 @@ module.exports = {
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
return; return;
} }
lastLiveName = name;
rememberDocument(name); rememberDocument(name);
if (follow) { if (follow) {
const hit = matchItem(name); try {
live.matched = Boolean(hit); const hit = matchItem(name, hintFrom(view));
if (hit) { live.matched = Boolean(hit);
live.currentScore = hit.score; if (hit) {
if (hit.item.id !== live.currentItemId) startItem(hit.item.id); live.currentScore = hit.score;
if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
} else {
// Said in the log, because on the wall it looks like the plan
// stopped following: the highlight stays where it was.
const staying = state.items.find((it) => it.id === live.currentItemId);
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
}
lastMatchError = '';
} catch (err) {
// Never silent: a matcher that throws would otherwise leave the
// highlight stuck with nothing in the log to say why.
const msg = err?.message || String(err);
if (msg !== lastMatchError) {
lastMatchError = msg;
log(`matching "${name}" failed: ${msg}`);
}
} }
} }
lastLiveName = name;
// The same document is also a possible service start / end cue. // The same document is also a possible service start / end cue.
syncServiceSafe(Date.now(), name); syncServiceSafe(Date.now(), name);
broadcast(); broadcast();
broadcastTimers(); broadcastTimers();
} }
function startItem(itemId) { /** Make an item current; `liveName` and `hit` say what placed it, for the log. */
function startItem(itemId, liveName, hit) {
const live = state.live; const live = state.live;
const now = Date.now(); const now = Date.now();
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) { if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
@@ -1120,12 +1149,18 @@ module.exports = {
live.currentItemId = itemId; live.currentItemId = itemId;
live.history[itemId] = { startedAt: now, endedAt: 0 }; live.history[itemId] = { startedAt: now, endedAt: 0 };
const item = state.items.find((it) => it.id === itemId); const item = state.items.find((it) => it.id === itemId);
log(`live item → ${item?.title || itemId}`); const how = hit
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
: '';
log(`live item → ${item?.title || itemId}${how}`);
} }
function nowNextUnavailable(reason, retryMs) { function nowNextUnavailable(reason, retryMs) {
const live = state.live; const live = state.live;
if (live.available || live.reason !== reason) { if (live.available || live.reason !== reason) {
// Every drop is in the log: a highlight that stopped moving is
// otherwise indistinguishable from a feed that went away.
log(`ProPresenter module feed unavailable — ${reason}`);
live.available = false; live.available = false;
live.reason = reason; live.reason = reason;
live.ppReachable = false; live.ppReachable = false;
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.3.1", "version": "1.3.2",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript", "description": "Live ProdCom transcript",
"permissions": [ "permissions": [
@@ -40,7 +40,7 @@
"instanceSchema": { "instanceSchema": {
"showAvatars": { "showAvatars": {
"type": "boolean", "type": "boolean",
"label": "Show channel icons", "label": "Channel icons",
"default": true "default": true
} }
} }
+39 -9
View File
@@ -6,6 +6,12 @@
in /admin; the tile only renders. */ in /admin; the tile only renders. */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
</div>`; </div>`;
const screenEl = root.querySelector('.nn-screen'); const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top'); const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist'); const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live'); const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text'); const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now'); const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box'); const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title'); let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta'); const nowMetaEl = root.querySelector('.nn-meta');
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list'); const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more'); const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() { function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles return moduleApi.instanceSettings;
} }
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
} }
function displayName(raw) { function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
} }
function itemTypeTag(type) { function itemTypeTag(type) {
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0; const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0; const current = state.currentSlide || 0;
if (total > 0 && current > 0) { if (!on('showSlideCount')) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current); const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) { } else if (total > 0) {
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = ''; nowMetaEl.textContent = '';
} }
if (prefs().showProgress && total > 0) { if (on('showProgress') && total > 0) {
progressEl.hidden = false; progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`; progressFillEl.style.width = `${pct}%`;
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)'; nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl); row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'nn-count-chip'; chip.className = 'nn-count-chip';
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row); nextListEl.appendChild(row);
} }
nextLabelEl.hidden = upcoming.length === 0; nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
nextMoreEl.hidden = true; nextMoreEl.hidden = true;
markNextOverflow(); markNextOverflow();
requestAnimationFrame(markNextOverflow); requestAnimationFrame(markNextOverflow);
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
screenEl.classList.toggle('is-offline', feedOffline); screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; // Trouble always shows, whatever is switched off: a tile that has stopped
topRow.classList.toggle('hidden-row', !showTop); // updating must never look like one with nothing live.
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; const trouble = feedOffline || !state?.reachable || !state?.enabled;
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive(); renderLive();
renderNow(); renderNow();
renderNext(); if (!nowOnly && on('showNext')) renderNext();
} }
/* ── sizing: --unit scales the whole card to the tile ───────────── */ /* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
function applyUnit() { function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
+78 -10
View File
@@ -1,32 +1,100 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.0.1", "version": "1.1.1",
"proddash": ">=1.1.0", "proddash": ">=1.5.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } {
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
], ],
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 3, "h": 3 }, "minSize": {
"defaultSize": { "w": 4, "h": 5 }, "w": 3,
"h": 3
},
"defaultSize": {
"w": 4,
"h": 5
},
"configSchema": { "configSchema": {
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"default": { "host": "", "port": 1025 } "default": {
"host": "",
"port": 1025
}
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "Password",
"default": "" "default": ""
} }
}, },
"instanceSchema": { "instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, "cleanTitles": {
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, "type": "switch",
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } "label": "Tidy titles",
"default": true
},
"showPlaylist": {
"type": "switch",
"label": "Playlist",
"default": true,
"group": "Show"
},
"showLive": {
"type": "switch",
"label": "Live status",
"default": true,
"group": "Show"
},
"showNowLabel": {
"type": "switch",
"label": "Now label",
"default": true,
"group": "Show"
},
"showSlideCount": {
"type": "switch",
"label": "Slide count",
"default": true,
"group": "Show"
},
"showProgress": {
"type": "switch",
"label": "Progress bar",
"default": true,
"group": "Show"
},
"showNext": {
"type": "switch",
"label": "Next list",
"default": true,
"group": "Show"
},
"showNextLabel": {
"type": "switch",
"label": "Next label",
"default": true,
"group": "Show"
},
"showTags": {
"type": "switch",
"label": "Item tags",
"default": true,
"group": "Show"
}
},
"instanceGroups": {
"Show": {
"collapsed": true,
"columns": 2
}
} }
} }
+37
View File
@@ -108,6 +108,43 @@ module.exports = {
}; };
}, },
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: true, columns: 2 } },
},
];
},
routes() { routes() {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
+48
View File
@@ -21,6 +21,51 @@
justify-content: center; justify-content: center;
} }
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */ /* ── top row ── */
[data-module="propresenter-now-next"] .nn-top { [data-module="propresenter-now-next"] .nn-top {
@@ -59,6 +104,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live { [data-module="propresenter-now-next"] .nn-live {
display: flex; display: flex;
@@ -71,6 +117,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot { [data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6); width: calc(var(--unit) * 0.6);
@@ -122,6 +169,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
} }
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box { [data-module="propresenter-now-next"] .nn-title-box {
flex: 1; flex: 1;
+29 -39
View File
@@ -15,7 +15,7 @@
Per-tile choices: the gear popover's static switches (whole sources, Per-tile choices: the gear popover's static switches (whole sources,
headings, status lines, the clock's format, the state colours) and the headings, status lines, the clock's format, the state colours) and the
"Timers ▾" header menu, which hides any single timer — keyed `source:id` timers header menu, which hides any single timer — keyed `source:id`
and saved with the layout via saveInstanceSettings. The header can be and saved with the layout via saveInstanceSettings. The header can be
hidden by the user, so the static switches stay the primary controls. hidden by the user, so the static switches stay the primary controls.
A solo tile's gear carries only the settings its one card can use (the A solo tile's gear carries only the settings its one card can use (the
@@ -196,6 +196,10 @@ function defaultStatus(timer, value, tone) {
/* ── the tile ─────────────────────────────────────────────────────────── */ /* ── the tile ─────────────────────────────────────────────────────────── */
/* The header menu's icon: a stopwatch, for the list of timers behind it.
Title-bar controls carry no words (module guide, rule 9). */
const TIMERS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2h4"/><path d="M12 14V9"/><circle cx="12" cy="14" r="8"/></svg>';
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
@@ -227,7 +231,7 @@ export default function create({ root, moduleApi }) {
} }
/* Which picker entry this tile was added as (the module's tiles() list): /* Which picker entry this tile was added as (the module's tiles() list):
'' or 'all' = every timer, selectable via the Timers ▾ menu; '' or 'all' = every timer, selectable via the timers menu;
'solo:<source>:<id>' = exactly one card, no menu, no headings. The 'solo:<source>:<id>' = exactly one card, no menu, no headings. The
earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */ earlier entries 'timer:<uuid>' and 'ltc' map onto the new keys. */
const variant = String(moduleApi.variant || ''); const variant = String(moduleApi.variant || '');
@@ -235,8 +239,6 @@ export default function create({ root, moduleApi }) {
: variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}` : variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}`
: variant.startsWith('solo:') ? variant.slice('solo:'.length) : variant.startsWith('solo:') ? variant.slice('solo:'.length)
: ''; : '';
/** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */
let lastSoloLabel = '';
/* ── DOM skeleton ───────────────────────────────────────────────── */ /* ── DOM skeleton ───────────────────────────────────────────────── */
@@ -283,8 +285,8 @@ export default function create({ root, moduleApi }) {
let menuEl = null; // the open menu's element (filled on each open) let menuEl = null; // the open menu's element (filled on each open)
const itemsMenu = solo ? null : moduleApi.header.addMenu({ const itemsMenu = solo ? null : moduleApi.header.addMenu({
label: 'Timers ▾', icon: TIMERS_SVG,
title: 'Choose timers', title: 'Timers',
build(menu) { build(menu) {
// the shell hands over an emptied element on every open // the shell hands over an emptied element on every open
menu.classList.add('tm-menu'); menu.classList.add('tm-menu');
@@ -292,6 +294,12 @@ export default function create({ root, moduleApi }) {
rebuildMenu(true); rebuildMenu(true);
}, },
}); });
// The icon carries the name; a count rides alongside it while some timers
// are hidden, so the bar says "filtered" without saying it in words.
const itemsCountEl = document.createElement('span');
itemsCountEl.className = 'tm-count';
itemsCountEl.hidden = true;
if (itemsMenu) itemsMenu.button.appendChild(itemsCountEl);
function menuRows() { function menuRows() {
const rows = []; const rows = [];
@@ -319,7 +327,9 @@ export default function create({ root, moduleApi }) {
if (!itemsMenu) return; if (!itemsMenu) return;
const rows = menuRows(); const rows = menuRows();
const visible = rows.filter((r) => !hidden.has(r.key)).length; const visible = rows.filter((r) => !hidden.has(r.key)).length;
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`); const filtering = visible !== rows.length;
itemsCountEl.textContent = filtering ? `${visible}/${rows.length}` : '';
itemsCountEl.hidden = !filtering;
} }
let menuSignature = ''; let menuSignature = '';
@@ -364,22 +374,21 @@ export default function create({ root, moduleApi }) {
/* ── state → what this tile shows ───────────────────────────────── */ /* ── state → what this tile shows ───────────────────────────────── */
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ /** [{ id, source, items: [{ key, source, timer }] }] */
function visibleGroups() { function visibleGroups() {
if (!state) return []; if (!state) return [];
if (solo) { if (solo) {
for (const source of state.sources) { for (const source of state.sources) {
for (const timer of source.timers) { for (const timer of source.timers) {
if (keyOf(source, timer) !== solo) continue; if (keyOf(source, timer) !== solo) continue;
lastSoloLabel = timer.label;
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }]; return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
} }
} }
// The timer this tile was added for isn't offered (yet, or any more) — // The timer this tile was added for is not offered right now. Nothing is
// keep a clearly-labeled card up rather than erroring. // drawn for it — no placeholder that reads like a timer, and never the
const sourceId = solo.slice(0, solo.indexOf(':')); // full list in its place. The shell folds the tile away on its next
const source = state.sources.find((s) => s.id === sourceId) || null; // registry refresh (module guide, Presenting multiple tiles).
return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }]; return [];
} }
const groups = []; const groups = [];
for (const source of state.sources) { for (const source of state.sources) {
@@ -439,7 +448,7 @@ export default function create({ root, moduleApi }) {
/** The parts of a card that only change with the snapshot (label, kind). */ /** The parts of a card that only change with the snapshot (label, kind). */
function updateCard(card, item) { function updateCard(card, item) {
card.item = item; card.item = item;
card.isClock = !item.missing && item.source?.kind === 'clock'; card.isClock = item.source?.kind === 'clock';
const isLtc = item.source?.kind === 'ltc'; const isLtc = item.source?.kind === 'ltc';
const badge = card.nameEl.querySelector('.tm-ltc-badge'); const badge = card.nameEl.querySelector('.tm-ltc-badge');
if (isLtc && !badge) { if (isLtc && !badge) {
@@ -450,29 +459,13 @@ export default function create({ root, moduleApi }) {
} else if (!isLtc && badge) { } else if (!isLtc && badge) {
badge.remove(); badge.remove();
} }
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label; const label = card.isClock ? '' : item.timer.label;
if (card.labelEl.textContent !== label) { if (card.labelEl.textContent !== label) {
setText(card.labelEl, label); setText(card.labelEl, label);
needFit = true; // the title's size follows its length needFit = true; // the title's size follows its length
} }
} }
function updateMissingCard(card) {
const source = card.item.source;
setText(card.timeEl, '—');
card.chars = 5;
let note;
if (!source) note = 'Source not available — check Admin';
else if (source.status === 'ok') note = `Not offered by ${source.label}`;
else note = '—';
setText(card.statusEl, note);
card.statusEl.hidden = false;
card.statusEl.className = 'tm-status is-note';
card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted';
card.detailEl.hidden = true;
setCardClass(card, 'tm-card is-muted is-missing');
}
/* ── clocks ─────────────────────────────────────────────────────── */ /* ── clocks ─────────────────────────────────────────────────────── */
function clockOptions() { function clockOptions() {
@@ -621,10 +614,6 @@ export default function create({ root, moduleApi }) {
let charsChanged = false; let charsChanged = false;
for (const card of cards.values()) { for (const card of cards.values()) {
const { item } = card; const { item } = card;
if (item.missing) {
updateMissingCard(card);
continue;
}
const { source, timer } = item; const { source, timer } = item;
let text; let text;
let tone; let tone;
@@ -723,8 +712,9 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('connecting', say(waiting)); moduleApi.setStatus('connecting', say(waiting));
return; return;
} }
const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0); const n = groups.reduce((a, g) => a + g.items.length, 0);
moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`); if (solo && n === 0) moduleApi.setStatus('connecting', 'Not offered right now');
else moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
} }
/* ── main render: diff groups and cards, update in place ────────── */ /* ── main render: diff groups and cards, update in place ────────── */
@@ -779,7 +769,7 @@ export default function create({ root, moduleApi }) {
let hint = ''; let hint = '';
const groups = visibleGroups(); const groups = visibleGroups();
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.'; if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.'; else if (state && !groups.length && !solo) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
hintEl.hidden = !hint; hintEl.hidden = !hint;
hintEl.textContent = hint; hintEl.textContent = hint;
groupsEl.hidden = Boolean(hint); groupsEl.hidden = Boolean(hint);
+3 -4
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.3.0", "version": "1.4.1",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": [ "provides": [
@@ -112,7 +112,7 @@
}, },
"instanceGroups": { "instanceGroups": {
"Show": { "Show": {
"collapsed": false, "collapsed": true,
"columns": 2 "columns": 2
}, },
"Clock": { "Clock": {
@@ -121,8 +121,7 @@
}, },
"Colours": { "Colours": {
"collapsed": true, "collapsed": true,
"columns": 2, "columns": 2
"help": "The time's colour per state. Left at the default, a colour follows the dashboard theme; one you change here wins over it."
} }
}, },
"instanceSchema": { "instanceSchema": {
+9 -2
View File
@@ -295,7 +295,6 @@
} }
/* A solo tile whose timer is no longer offered. */ /* A solo tile whose timer is no longer offered. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
/* ── the LTC card: visually its own thing ── */ /* ── the LTC card: visually its own thing ── */
@@ -321,7 +320,7 @@
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } @keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ /* ── the timers menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item { [data-module="propresenter-timers"] .tm-menu-item {
display: flex; display: flex;
@@ -383,3 +382,11 @@
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
/* the count beside the title-bar timers icon, while some are hidden */
[data-module="propresenter-timers"] .tm-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-timers"] .tm-count[hidden] { display: none; }
+23 -12
View File
@@ -141,13 +141,17 @@ const sendTarget = (() => {
/** /**
* Where a send goes, given the configured channels in admin order: the * Where a send goes, given the configured channels in admin order: the
* selection while it is still configured and its transcript is on the * selection while it is still configured and its transcript is on the
* page; otherwise the first configured channel. * page, else the first configured channel that IS on this page — with one
* transcript up, that one — and only if the page shows none, the first
* configured channel. A send must never quietly go to a channel the
* operator cannot see.
*/ */
resolve(configured) { resolve(configured) {
const list = Array.isArray(configured) ? configured : []; const list = Array.isArray(configured) ? configured : [];
if (!list.length) return ''; if (!list.length) return '';
if (id && list.includes(id) && this.hasTranscript(id)) return id; if (id && list.includes(id) && this.hasTranscript(id)) return id;
return list[0]; const onPage = list.find((c) => this.hasTranscript(c));
return onPage || list[0];
}, },
on(fn) { on(fn) {
bus.addEventListener('change', fn); bus.addEventListener('change', fn);
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (stopped || !state) return; if (stopped || !state) return;
if (token) { if (token) {
if (composeEls) { if (composeEls) {
composeEls.line.textContent = sendingLine(); if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
let composeEls = null; let composeEls = null;
function buildCompose() { function buildCompose() {
compose.textContent = ''; compose.textContent = '';
const head = el('div', 'sl-compose-head'); // Send message names the channel in the box's own placeholder and locks
const line = el('span', 'sl-sending', sendingLine()); // from the title bar, so a line above the field would only say both
const lockLink = el('button', 'sl-lock-link', 'Lock'); // again. Quick replies has no placeholder — there it is the only place
lockLink.type = 'button'; // the target is named, so it stays.
lockLink.title = 'Lock now'; let line = null;
lockLink.addEventListener('click', () => lock('Locked')); if (mode !== 'send') {
head.append(line, lockLink); const head = el('div', 'sl-compose-head');
compose.appendChild(head); line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
}
const foot = el('div', 'sl-compose-foot'); const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg'); const msg = el('div', 'sl-msg');
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock(''); if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls) { if (composeEls) {
composeEls.line.textContent = sendingLine(); if (composeEls.line) composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"id": "slack", "id": "slack",
"name": "Slack", "name": "Slack",
"version": "1.2.0", "version": "1.2.1",
"proddash": ">=1.4.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, plus PIN-protected sending as the location",
"client": "client.js", "client": "client.js",
@@ -21,7 +21,7 @@
"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 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."
}, },
"Channels": { "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 to the first channel here until one is clicked. 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." "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": { "Sending": {
"toggle": "sendEnabled", "toggle": "sendEnabled",
@@ -49,7 +49,7 @@
"default": [], "default": [],
"group": "Channels", "group": "Channels",
"optionsRoute": "/channels", "optionsRoute": "/channels",
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first." "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."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",
+531
View File
@@ -0,0 +1,531 @@
/**
* SPL Meter tiles.
*
* Four kinds, all fed by one SSE stream from the module's server:
*
* readout the number, big
* bars one level bar per channel, with peak hold
* rta spectrum now, folded to the banding this tile wants
* spectrogram the same bands over time, either axis
*
* Nothing here measures anything. The capture child already sent every
* averaging and the finest banding, and each tile folds that down to the
* view it was configured for. That is the whole design: two dashboards can
* watch one room through different settings without either disturbing the
* measurement or the other dashboard.
*/
const FLOOR = -200;
/** Retention per frame for the RTA's smoothing setting. */
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
export default function create({ root, moduleApi }) {
const variant = moduleApi.variant || 'readout';
let stream = null;
let latest = null;
let raf = 0;
root.classList.add('spl');
root.dataset.variant = variant;
const view = buildView(variant, root, moduleApi);
function onState(payload) {
latest = payload;
if (payload.error) {
moduleApi.setStatus('error', payload.error);
} else if (!payload.frame) {
moduleApi.setStatus('connecting', 'waiting for the first reading');
} else if (!payload.calibrated) {
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
} else {
moduleApi.setStatus('ok', '');
}
// Frames land 20×/s; paint on a frame the browser was going to draw
// anyway rather than fighting it for every one.
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
}
return {
start() {
moduleApi.setStatus('connecting', 'connecting');
moduleApi.fetch('/state')
.then((r) => r.json())
.then(onState)
.catch(() => { /* the stream will bring it */ });
// Named events go under `events` — a top-level `state(ev)` is silently
// never registered, and the tile just sits on whatever /state gave it.
stream = moduleApi.sse('/stream', {
events: {
state(ev) {
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
},
},
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
});
},
stop() {
if (stream) { stream.close(); stream = null; }
if (raf) { cancelAnimationFrame(raf); raf = 0; }
view.destroy();
},
onResize() { view.resize(); },
};
}
/* ── shared helpers ─────────────────────────────────────────────────── */
function pickChannel(payload, settings) {
if (!payload || !payload.frame) return null;
const want = Number(settings.channel);
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
}
/**
* The level this tile asked for: an averaging, or one of the fixed metrics.
*
* Tiles saved by an earlier version carry metrics that no longer exist —
* "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere
* must not start reading dashes because a setting was renamed, so those map
* forward instead of falling through to undefined.
*/
const LEGACY_METRIC = { spl: 'level', leq: 'level' };
const LEGACY_AVERAGING = { leq: 'inf' };
function readValue(ch, settings) {
const raw = settings.metric || 'level';
const metric = LEGACY_METRIC[raw] || raw;
if (metric !== 'level') {
return metric in ch ? ch[metric] : ch.fast;
}
const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast';
return avg in ch ? ch[avg] : ch.fast;
}
function fmt(db) {
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
return db.toFixed(1);
}
function themeColor(el, name, fallback) {
const v = getComputedStyle(el).getPropertyValue(name).trim();
return v || fallback;
}
function hzLabel(f) {
if (f >= 1000) {
const k = f / 1000;
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
}
return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10);
}
/**
* Fold the 1/48-octave bands on the wire down to this tile's banding.
*
* Every banding offered divides 48, so a coarser band is an exact sum of
* finer ones in the power domain — no interpolation, no resampling. Groups
* are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on
* the values an RTA is expected to show.
*
* @returns {{freq:number[], db:number[]}}
*/
function foldBands(bands, bandN0, perOctave, denom) {
const g = Math.max(1, Math.round(perOctave / denom));
const power = new Map();
for (let i = 0; i < bands.length; i++) {
const n = bandN0 + i;
const k = Math.round(n / g);
const p = 10 ** (bands[i] / 10);
power.set(k, (power.get(k) || 0) + p);
}
const keys = [...power.keys()].sort((a, b) => a - b);
return {
freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)),
db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))),
};
}
/**
* Which frequency labels fit, most important first.
*
* Decades earn their place before octaves, octaves before thirds, and
* anything that would collide with a label already placed is dropped. That
* is what makes the axis thin out as a tile shrinks instead of turning into
* a smear.
*/
const LABEL_TIERS = [
[100, 1000, 10000],
[31.5, 63, 250, 500, 2000, 4000, 8000, 20000],
[20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000],
];
function chooseLabels(freqs, positionOf, extent, minGapPx) {
const placed = [];
for (const tier of LABEL_TIERS) {
for (const f of tier) {
if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue;
const pos = positionOf(f);
if (pos < 0 || pos > extent) continue;
if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue;
placed.push({ f, pos });
}
}
return placed.sort((a, b) => a.pos - b.pos);
}
/* ── views ──────────────────────────────────────────────────────────── */
function buildView(variant, root, moduleApi) {
switch (variant) {
case 'bars': return barsView(root, moduleApi);
case 'rta': return spectrumView(root, moduleApi, false);
case 'spectrogram': return spectrumView(root, moduleApi, true);
default: return readoutView(root, moduleApi);
}
}
function readoutView(root, moduleApi) {
root.innerHTML = `
<div class="spl-readout">
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
<div class="spl-sub"></div>
</div>`;
const num = root.querySelector('.spl-num');
const unit = root.querySelector('.spl-unit');
const sub = root.querySelector('.spl-sub');
const box = root.querySelector('.spl-readout');
const value = root.querySelector('.spl-value');
let fitKey = '';
/**
* Grow the number until it fills the tile, measuring the text we actually
* have rather than assuming a digit count.
*
* The CSS can only guess at width — it has to pick a cqw coefficient for
* some number of characters, and then "-100.5" overflows the tile that
* "-45.9" merely filled. Measuring costs one layout read, and only when
* the tile size or the length of the reading changes.
*/
function fit() {
const w = root.clientWidth, h = root.clientHeight;
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
if (key === fitKey || w < 2 || h < 2) return;
fitKey = key;
const TRIAL = 100;
num.style.fontSize = `${TRIAL}px`;
unit.style.fontSize = `${TRIAL * 0.26}px`;
const natural = value.scrollWidth || 1;
// Leave the caption its room when it is showing, and a little air either
// way so descenders and the tile's padding never clip.
const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0;
const byHeight = (h - subH) * 0.92;
const byWidth = ((w - 8) * TRIAL) / natural;
const size = Math.max(12, Math.min(byHeight, byWidth));
num.style.fontSize = `${size}px`;
unit.style.fontSize = `${size * 0.26}px`;
}
return {
render(payload) {
const s = moduleApi.instanceSettings;
const ch = pickChannel(payload, s);
if (!payload || payload.error || !ch) {
num.textContent = '––.–';
sub.textContent = payload && payload.error ? payload.error : '';
box.dataset.level = 'idle';
fit();
return;
}
const value = readValue(ch, s);
num.textContent = fmt(value);
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
box.dataset.level = !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
const raw = s.metric || 'level';
const metric = LEGACY_METRIC[raw] || raw;
const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
const label = metric === 'level'
? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast')
: ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric);
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
if (ch.clip) parts.push('CLIP');
if (!payload.calibrated) parts.push('uncalibrated');
sub.hidden = s.caption === false;
sub.textContent = parts.join(' · ');
fit();
},
resize() { fitKey = ''; fit(); },
destroy() { root.innerHTML = ''; },
};
}
function barsView(root, moduleApi) {
root.innerHTML = '<div class="spl-bars"></div>';
const host = root.querySelector('.spl-bars');
let built = '';
return {
render(payload) {
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
const chans = payload.frame.ch;
const key = chans.map((c) => c.i).join(',');
if (key !== built) {
built = key;
host.innerHTML = chans.map((c) => `
<div class="spl-bar" data-ch="${c.i}">
<div class="spl-bar-label">${c.i}</div>
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
<div class="spl-bar-value">––.–</div>
</div>`).join('');
}
const s = moduleApi.instanceSettings;
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
const showPeak = s.peakHold !== false;
const lo = payload.calibrated ? 40 : -80;
const hi = payload.calibrated ? 110 : 0;
for (const c of chans) {
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
if (!el) continue;
const value = readValue(c, { averaging: s.averaging });
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
const peak = el.querySelector('.spl-bar-peak');
peak.style.display = showPeak ? '' : 'none';
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
el.querySelector('.spl-bar-value').textContent = fmt(value);
el.dataset.level = !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip);
}
},
resize() {},
destroy() { root.innerHTML = ''; },
};
}
/**
* RTA bars, or the same folded bands scrolling as a spectrogram.
*
* Canvas for both, because both redraw wholesale many times a second and a
* few hundred animating DOM nodes is how a dashboard starts dropping frames
* on the machine that is also running the service.
*/
function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let painted = false; // spectrogram: has the background been laid down
let smoothed = null; // RTA: displayed values, damped toward the real ones
let smoothedFor = '';
let axisBtn = null;
let axisOverride = null; // set by the title-bar toggle, which never remounts us
function axis() {
return axisOverride || moduleApi.instanceSettings.axis || 'x';
}
if (scrolling) {
// The axis lives here and nowhere else (rule 11): a tile does not offer
// the same action twice. Icon only, tooltip carries the words (rule 9).
// saveInstanceSettings deliberately does not remount us, so the new
// value is held here as well and read back by axis().
const swapIcon = '<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="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
axisBtn = moduleApi.header.addButton({
icon: swapIcon,
title: describe(),
onClick() {
axisOverride = axis() === 'x' ? 'y' : 'x';
moduleApi.saveInstanceSettings({ axis: axisOverride });
painted = false; // the picture means nothing in the other orientation
axisBtn.title = describe();
},
});
}
function sizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(root.clientWidth));
const h = Math.max(1, Math.floor(root.clientHeight));
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
return true;
}
sizeCanvas();
function norm(db, calibrated) {
const lo = calibrated ? 30 : -100;
const hi = calibrated ? 110 : -10;
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
}
function heat(v) {
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
const i = Math.floor(x), t = x - i;
const a = stops[i], b = stops[i + 1] || stops[i];
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
}
return {
render(payload) {
sizeCanvas();
const w = canvas.clientWidth, h = canvas.clientHeight;
const bg = themeColor(root, '--panel', '#141414');
const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
painted = false;
return;
}
const s = moduleApi.instanceSettings;
const want = Number(s.channel);
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
const raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
if (!raw.length) return;
const denom = Number(s.banding) || 3;
const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom);
const freqs = folded.freq;
let values = folded.db;
const grid = s.gridlines === true;
const showLabels = s.labels !== false;
const axisPad = showLabels || grid ? 13 : 0;
if (!scrolling) {
// Damp the bars toward the measurement. Cosmetic: `values` is what
// gets drawn, never what gets reported.
const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium;
const key = `${denom}:${values.length}`;
if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) {
smoothed = values.slice();
smoothedFor = key;
} else if (keep > 0) {
for (let i = 0; i < values.length; i++) {
smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep);
}
} else {
smoothed = values.slice();
}
values = smoothed;
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
const plotH = h - axisPad;
const bw = w / values.length;
const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
const labels = chooseLabels(freqs, xOf, w, 34);
if (grid) {
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5;
ctx.beginPath();
for (const l of labels) {
const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
}
for (let g = 0.2; g < 1; g += 0.2) {
const y = Math.round(plotH * g) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y);
}
ctx.stroke(); ctx.globalAlpha = 1;
}
for (let i = 0; i < values.length; i++) {
const v = norm(values[i], payload.calibrated);
const bh = v * plotH;
ctx.fillStyle = heat(v);
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
}
if (showLabels) {
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
return;
}
// ── spectrogram ──
const timeX = axis() === 'x';
const colW = 2;
const gutter = showLabels ? axisPad : 0;
// Frequency occupies whichever axis time does not.
const freqExtent = timeX ? h - gutter : w - gutter;
const timeExtent = timeX ? w : h;
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
// Source rect in device pixels, destination in CSS pixels: the context
// carries a devicePixelRatio transform but the canvas as an image
// source does not, and letting drawImage infer them scales the picture
// up and pushes every column off the canvas.
if (timeX) {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
} else {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
}
const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) {
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1);
} else {
// Low frequencies at the left.
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW);
}
}
if (grid || showLabels) {
const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
};
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
ctx.fillStyle = bg;
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h);
ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
for (const l of labels) {
if (timeX) {
const y = Math.round(l.pos) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y);
} else {
const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, h);
}
}
ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center';
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
}
},
resize() { sizeCanvas(); },
destroy() { root.innerHTML = ''; },
};
}
+94
View File
@@ -0,0 +1,94 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.3.1",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
{
"kind": "microphone",
"reason": "Measuring sound level from an audio input on this machine"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 3,
"h": 2
},
"configGroups": {
"Input": {
"toggle": "enabled"
},
"Measurement": {
"columns": 2
},
"Calibration": {
"collapsed": "whenSet",
"help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so."
}
},
"configSchema": {
"enabled": {
"type": "switch",
"label": "Sound level measurement",
"group": "Input",
"default": false
},
"device": {
"type": "select",
"label": "Audio device",
"group": "Input",
"showWhen": "enabled",
"default": "",
"optionsRoute": "/devices"
},
"channels": {
"type": "string",
"label": "Channels on that device (1-based, comma separated)",
"group": "Input",
"showWhen": "enabled",
"default": "1",
"help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair."
},
"weighting": {
"type": "select",
"label": "Weighting",
"group": "Measurement",
"default": "A",
"options": [
{
"value": "A",
"label": "A — how loud it sounds"
},
{
"value": "C",
"label": "C — keeps the low end"
},
{
"value": "Z",
"label": "Z — no weighting at all"
}
]
},
"bands": {
"type": "switch",
"label": "Spectrum analysis",
"group": "Measurement",
"default": true,
"help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number."
},
"calibration": {
"type": "string",
"label": "Offsets in dB, one per channel",
"group": "Calibration",
"default": "",
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
}
}
}
+407
View File
@@ -0,0 +1,407 @@
'use strict';
/**
* SPL Meter server entry.
*
* Owns one capture child (spl-listener.js) and fans its frames out to every
* tile over SSE. Browsers never see the audio device, only the numbers.
*
* GET /stream → SSE, `state` events at the capture rate
* GET /state → the latest frame, for an instant first paint
* GET /devices → input devices, for the admin page's picker
* GET /channels → configured channels, for the tile gear picker
* POST /reset → clear Leq and peak hold
* GET /measure → what a calibrator is reading right now, so the
* admin page can tell the operator what to type
*
* Frames arrive 20×/s. That is the right rate for a meter to feel live and
* the wrong rate to re-broadcast blindly, so the SSE fan-out drops a frame
* whose numbers round to what the last one said.
*/
const path = require('path');
const { createSplListener } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
/** How long /measure averages for. Long enough to ride out a wobble, short
enough that nobody standing at the microphone gets bored. */
const MEASURE_MS = 3000;
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
const PING_MS = 15000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null;
/* ── shared tile settings ───────────────────────────────────────────── */
const CHANNEL_FIELD = {
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
};
/**
* Averaging is a view choice, not a measurement one: the capture child
* computes all four every frame and sends all four, so one dashboard on Fast
* and another on Inf are reading the same measurement two ways.
*/
function averagingField() {
return {
type: 'select', label: 'Averaging', default: 'fast',
options: [
{ value: 'fast', label: 'Fast · 125 ms' },
{ value: 'slow', label: 'Slow · 1 s' },
{ value: 'inf', label: 'Inf · since reset' },
{ value: 'none', label: 'None' },
],
};
}
/**
* Banding folds the 1/48-octave bands on the wire down to something
* readable. Coarser is an exact sum of finer, so this costs nothing and
* disturbs no other tile.
*/
const BANDING_FIELD = {
type: 'select', label: 'Banding', default: '3',
options: [
{ value: '3', label: '1/3 octave' },
{ value: '6', label: '1/6 octave' },
{ value: '12', label: '1/12 octave' },
{ value: '24', label: '1/24 octave' },
{ value: '48', label: '1/48 octave' },
],
};
function parseChannels(raw) {
return String(raw || '1')
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
}
function parseCalibration(raw) {
if (!String(raw || '').trim()) return [];
return String(raw)
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
}
module.exports = {
init({ config, log }) {
const channels = parseChannels(config.channels);
const calibration = parseCalibration(config.calibration);
const moduleDir = __dirname;
state = {
channels,
calibrated: calibration.length === channels.length && channels.length > 0,
frame: null,
meta: null,
error: '',
streams: new Set(),
listener: null,
lastSent: '',
pinger: null,
};
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (!config.device) {
state.error = 'No audio device chosen yet';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (!channels.length) {
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (calibration.length && calibration.length !== channels.length) {
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
+ 'give one per channel, or leave the offsets empty';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
state.listener = createSplListener({
device: config.device,
channels,
weighting: config.weighting || 'A',
bands: config.bands !== false,
calibration,
moduleDir,
log,
onUpdate(update) {
if (!state) return; // a frame in flight when the module unmounted
if (update.error) {
if (update.error !== state.error) log(update.error);
state.error = update.error;
state.frame = null;
broadcast();
return;
}
state.error = '';
state.frame = update.frame;
state.meta = update.meta;
broadcast();
},
});
const mineStreams = state.streams;
state.pinger = setInterval(() => {
for (const res of mineStreams) {
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
}
}, PING_MS);
return {
stop() { teardown(); },
health() {
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated
? { status: 'ok', message: what }
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
},
};
},
/**
* Four tile kinds, each with only the settings that do something to it.
*
* An entry's own instanceSchema replaces the module's, which is the point:
* an RTA has no use for an alarm threshold and a readout has no use for a
* banding, and offering either is how a gear popover stops being read.
*
* Every setting here is a view choice. None of them reach the capture
* child — it already sends every averaging and the finest banding, so two
* dashboards can disagree about how to draw the same room.
*/
tiles() {
return [
{
id: 'readout', name: 'SPL', description: 'The level, big',
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
instanceGroups: { Thresholds: { columns: 2 } },
instanceSchema: {
channel: CHANNEL_FIELD,
averaging: averagingField(),
metric: {
type: 'select', label: 'Show', default: 'level',
options: [
{ value: 'level', label: 'Level' },
{ value: 'leq1m', label: 'Leq 1 min' },
{ value: 'leq10m', label: 'Leq 10 min' },
{ value: 'peakC', label: 'Peak C' },
],
},
caption: { type: 'switch', label: 'Caption', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
},
{
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
instanceGroups: { Thresholds: { columns: 2 } },
instanceSchema: {
averaging: averagingField(),
peakHold: { type: 'switch', label: 'Peak hold', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
},
{
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
instanceSchema: {
channel: CHANNEL_FIELD,
averaging: averagingField(),
banding: BANDING_FIELD,
smoothing: {
type: 'select', label: 'Smoothing', default: 'medium',
options: [
{ value: 'off', label: 'Off' },
{ value: 'light', label: 'Light' },
{ value: 'medium', label: 'Medium' },
{ value: 'heavy', label: 'Heavy' },
],
},
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
{
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
instanceSchema: {
channel: CHANNEL_FIELD,
banding: BANDING_FIELD,
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
];
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, 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',
});
res.write('retry: 3000\n\n');
try {
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
} catch { return; }
// Hold this mount's set, not `state`. teardown() nulls `state` and
// then ends every stream, which fires these close handlers — a
// handler that reached through `state` threw on null and took the
// whole server down with it, on any re-init with a browser
// connected. Deleting from a set nobody reads any more is harmless.
const streams = state.streams;
streams.add(res);
req.on('close', () => streams.delete(res));
},
/** 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) => {
try {
const devices = await listDevices();
json(res, 200, {
options: devices.map((d) => ({
value: d.name,
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
})),
});
} catch (err) {
log(`device list failed: ${err.message}`);
json(res, 200, { options: [] }); // the saved value stays selectable
}
},
/** Channel list for a tile's gear popover. */
'GET /channels': (req, res) => {
json(res, 200, {
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
});
},
'POST /reset': (req, res) => {
if (state.listener) state.listener.reset();
json(res, 200, { ok: true });
},
/**
* What the microphone is hearing right now, unweighted and
* uncalibrated, plus the offset that would make it read 94 dB.
*
* The module cannot write its own admin config, so this does not save
* anything — it hands back a number for the operator to paste into the
* Calibration box. Honest, if one step longer than it should be.
*/
'GET /measure': (req, res) => {
if (!state.listener || state.error) {
json(res, 409, { error: state.error || 'not measuring' });
return;
}
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|| state.channels[0];
const samples = [];
const started = Date.now();
const tick = setInterval(() => {
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
if (Date.now() - started < MEASURE_MS) return;
clearInterval(tick);
if (samples.length < 5) {
json(res, 200, {
channel: wanted,
error: 'No steady tone on that channel — check the calibrator is on and seated',
});
return;
}
// Mean of the linear powers, not of the decibels: averaging dB
// would quietly under-read a signal that wobbles at all.
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
const measured = 10 * Math.log10(power);
json(res, 200, {
channel: wanted,
measured: Math.round(measured * 100) / 100,
reference: CALIBRATOR_DB,
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
samples: samples.length,
});
}, 100);
req.on('close', () => clearInterval(tick));
},
};
},
};
/* ── helpers ────────────────────────────────────────────────────────── */
function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
return {
error: state.error,
calibrated: state.calibrated,
channels: state.channels,
weighting: state.meta ? state.meta.weighting : '',
bandCentres: state.meta ? state.meta.bands : [],
bandN0: state.meta ? state.meta.bandN0 : 0,
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
frame: state.frame,
};
}
function broadcast() {
if (!state) return; // a listener callback landing after teardown
const payload = snapshot();
const line = JSON.stringify(payload);
if (line === state.lastSent) return; // nothing moved since last frame
state.lastSent = line;
for (const res of state.streams) {
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
}
}
function teardown() {
if (!state) return;
if (state.listener) state.listener.stop();
clearInterval(state.pinger);
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
state.streams.clear();
state = null;
}
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
catch (e) { reject(e); }
});
});
}
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(text),
});
res.end(text);
}
+735
View File
@@ -0,0 +1,735 @@
// spl-capture — calibrated sound-level measurement from a CoreAudio input.
//
// Sibling of the Timers module's ltc-capture: same device enumeration, same
// "stdout is data, stderr is talk" contract, same one-file-no-dependencies
// rule. The difference is where the work happens. LTC ships raw samples to
// Node because the decode is cheap and single-channel; sound level is
// filtering and FFTs over every channel at the device's rate, so the
// 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:
//
// swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
//
// spl-capture --list
// every input-capable device: name, input channels, sample rate
// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0]
// measures those 1-based channels and writes one JSON line per frame
// to stdout, 20×/s. --cal takes one dB offset per channel, in the same
// order, from the calibration route (94 dB reference minus what we
// measured). With no --cal the numbers are dBFS-relative and the line
// says calibrated:false, so nothing downstream can mistake them for
// real SPL.
//
// Per channel, per frame: the level at EVERY averaging a dashboard might
// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
// and spectrogram tiles, only when --bands is passed.
//
// Everything is sent every frame on purpose. Two dashboards watching the
// same room may want different averaging and different banding, and neither
// choice may disturb the other or restart capture. So the measurement is
// made once, at the finest resolution anyone asked for, and each tile folds
// it down to the view it wants. Nothing here is per-viewer state.
//
// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz.
// Verified against the standard's table: within 0.15 dB from 31.5 Hz to
// 4 kHz. The top octave is where the bilinear transform runs out of room —
// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error
// on a broadband programme number (A-weighting has already thrown 6.6 dB
// away up there) but it is a real limit, and the fix if it ever matters is
// to run the weighting at 96 kHz. `--selftest` prints the table so this
// claim can be checked rather than believed.
//
// macOS asks for microphone permission on first use, and the prompt belongs
// to whatever launched this — the ProdDash launcher, or Terminal. A server
// started over SSH gets SILENT ZEROS from every input with no error at all,
// which shows up downstream as a meter pinned at the floor.
import AVFoundation
import Accelerate
import CoreAudio
import Foundation
func fail(_ msg: String) -> Never {
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
exit(2)
}
func note(_ msg: String) {
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
}
/* ── CoreAudio device enumeration ───────────────────────────────────── */
struct InputDevice {
let id: AudioDeviceID
let name: String
let uid: String
let channels: Int
let sampleRate: Double
}
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
var addr = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var ref: Unmanaged<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let err = withUnsafeMutablePointer(to: &ref) {
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
}
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
return value as String
}
func sampleRateProperty(_ id: AudioObjectID) -> Double {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var rate: Float64 = 0
var size = UInt32(MemoryLayout<Float64>.size)
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
return rate
}
func inputChannelCount(_ id: AudioDeviceID) -> Int {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
let listPtr = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { listPtr.deallocate() }
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
let buffers = UnsafeMutableAudioBufferListPointer(
listPtr.assumingMemoryBound(to: AudioBufferList.self))
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
}
func listInputDevices() -> [InputDevice] {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr else { return [] }
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr else { return [] }
var out: [InputDevice] = []
for id in ids {
let channels = inputChannelCount(id)
if channels == 0 { continue }
out.append(InputDevice(id: id,
name: stringProperty(id, kAudioObjectPropertyName),
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
channels: channels,
sampleRate: sampleRateProperty(id)))
}
return out
}
/* ── frequency weighting (IEC 61672) ────────────────────────────────── */
/// Pole frequencies from the standard.
private let wf1 = 2 * Double.pi * 20.598997
private let wf2 = 2 * Double.pi * 107.65265
private let wf3 = 2 * Double.pi * 737.86223
private let wf4 = 2 * Double.pi * 12194.217
/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2].
///
/// `warpAt` prewarps the transform so that section's critical frequency
/// lands where the analogue prototype put it. Without it the plain
/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about
/// 3 dB at 16 kHz for the 12.2 kHz pole pair.
func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double),
fs: Double, warpAt: Double = 0) -> [Double] {
let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs
let c2 = c * c
let B0 = b.0 * c2 + b.1 * c + b.2
let B1 = 2 * b.2 - 2 * b.0 * c2
let B2 = b.0 * c2 - b.1 * c + b.2
let A0 = a.0 * c2 + a.1 * c + a.2
let A1 = 2 * a.2 - 2 * a.0 * c2
let A2 = a.0 * c2 - a.1 * c + a.2
return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0]
}
/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz
/// and to print the verification table.
func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double {
let th = 2 * Double.pi * f / fs
var reT = 1.0, imT = 0.0
for s in stride(from: 0, to: secs.count, by: 5) {
let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4])
let nr = b0 + b1 * cos(th) + b2 * cos(2 * th)
let ni = -(b1 * sin(th) + b2 * sin(2 * th))
let dr = 1 + a1 * cos(th) + a2 * cos(2 * th)
let di = -(a1 * sin(th) + a2 * sin(2 * th))
let dd = dr * dr + di * di
guard dd > 0 else { return 0 }
let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd
let t = reT * qr - imT * qi
imT = reT * qi + imT * qr
reT = t
}
return (reT * reT + imT * imT).squareRoot()
}
enum Weighting: String {
case a = "A", c = "C", z = "Z"
/// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz.
func sections(fs: Double) -> [Double] {
var s: [Double]
switch self {
case .a:
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
+ bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs)
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
case .c:
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
case .z:
return [1, 0, 0, 0, 0] // flat, one pass-through section
}
let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs)
s[0] *= g; s[1] *= g; s[2] *= g
return s
}
}
/// A weighting filter bound to one channel. The coefficients could be shared
/// but the delay line cannot, and a setup per channel costs a few hundred
/// bytes — not worth the aliasing risk of sharing one.
final class WeightingFilter {
private let setup: vDSP_biquad_Setup
private var delay: [Float]
private let sections: vDSP_Length
init(_ weighting: Weighting, fs: Double) {
let coeffs = weighting.sections(fs: fs)
sections = vDSP_Length(coeffs.count / 5)
guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else {
fail("cannot build \(weighting.rawValue)-weighting filter")
}
setup = s
delay = [Float](repeating: 0, count: Int(sections) * 2 + 2)
}
deinit { vDSP_biquad_DestroySetup(setup) }
func process(_ input: UnsafePointer<Float>, _ output: inout [Float], _ n: Int) {
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
}
}
/* ── third-octave band plan for the RTA ─────────────────────────────── */
/// The wire always carries 1/48-octave bands, and a tile folds them down to
/// whatever banding it wants to draw.
///
/// That works exactly because every banding the dashboard offers divides 48:
/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
/// are additive, so a fold is a sum and nothing is approximated. The
/// alternative — the capture re-banding on request — would mean one dashboard
/// choosing 1/6 changes what every other dashboard sees.
///
/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps
/// the folded centres on the standard values.
let bandN0 = -271 // ≈ 20 Hz
let bandN1 = 207 // ≈ 20 kHz
let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) }
/// FFT bin ranges per band, clamped to what the transform can actually
/// resolve.
///
/// This is the one caveat a reader of the RTA needs and the gear is not
/// allowed to carry (module rule 5, no explanatory text in a tile's
/// settings): below roughly 200 Hz a 1/48-octave band is narrower than one
/// bin, so neighbouring bands start sharing bins and the low end reads
/// smoother than it really is. That is the window length talking, not the
/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution
/// transform is the real fix if the bottom octaves ever have to be read at
/// full resolution.
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
let binHz = fs / Double(fftSize)
let maxBin = fftSize / 2 - 1
return bandCentres.map { fc in
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin)))
}
}
/* ── per-channel measurement state ──────────────────────────────────── */
/// Everything the standard asks for on one channel.
///
/// SPL is an exponential moving average of weighted mean square — Fast is a
/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is
/// a plain energy average with no time constant at all, which is why it is
/// accumulated separately rather than read off the integrator.
final class ChannelMeter {
let index: Int // 1-based, as the operator counts them
private let fs: Double
private let calibration: Double // dB added to turn dBFS into dB SPL
private let weightFilter: WeightingFilter
private let cFilter: WeightingFilter
private var weighted: [Float]
private var cWeighted: [Float]
// One integrator per averaging the dashboard might ask for. They all run
// all the time and all go on the wire: a tile choosing "Slow" must not
// restart capture, because the next tile wants "Fast" off the same
// stream and a third wants Inf. Four extra doubles per channel is a
// rounding error next to the filtering; re-measuring per viewer is not.
private var msFast: Double = 0
private var msSlow: Double = 0
private var msNone: Double = 0 // this block only, no time constant
private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS
private let alphaFast: Double
private let alphaSlow: Double
// Leq accumulators: running since reset, plus rolling 1 and 10 minute.
private var leqSum: Double = 0
private var leqCount: Double = 0
private var ring: [Double] = [] // one mean-square per emitted frame
private let ringCapacity: Int
private var peakC: Double = 0 // linear, since reset
private var peakZ: Double = 0
private var clipped = false
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
framesPerSecond: Double) {
self.index = index
self.fs = fs
self.calibration = calibration
weightFilter = WeightingFilter(weighting, fs: fs)
cFilter = WeightingFilter(.c, fs: fs)
weighted = []
cWeighted = []
// The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
alphaFast = 1.0 - exp(-1.0 / (0.125 * fs))
alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs))
ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes
}
/// Feed one tap buffer for this channel.
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
if weighted.count < n {
weighted = [Float](repeating: 0, count: n)
cWeighted = [Float](repeating: 0, count: n)
}
weightFilter.process(samples, &weighted, n)
cFilter.process(samples, &cWeighted, n)
// Exponential integration over the weighted signal, sample by sample
// in the power domain. vDSP has no one-pole on squares, and at 48 kHz
// this is a few hundred thousand multiply-adds a second — noise next
// to the filtering above.
var fast = msFast, slow = msSlow, flat = flatMeanSquare
weighted.withUnsafeBufferPointer { w in
for i in 0..<n {
let v = Double(w[i]) * Double(w[i])
fast += alphaFast * (v - fast)
slow += alphaSlow * (v - slow)
let r = Double(samples[i])
flat += alphaFast * (r * r - flat)
}
}
msFast = fast; msSlow = slow; flatMeanSquare = flat
// Mean square of just this block — "None", the reading with no time
// constant at all. Also what Leq accumulates, since Leq is a plain
// energy average and has no time constant either.
var sumSq: Float = 0
weighted.withUnsafeBufferPointer { w in
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
}
msNone = Double(sumSq)
leqSum += Double(sumSq) * Double(n)
leqCount += Double(n)
var cPeak: Float = 0, zPeak: Float = 0
cWeighted.withUnsafeBufferPointer { c in
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
}
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
peakC = max(peakC, Double(cPeak))
peakZ = max(peakZ, Double(zPeak))
if zPeak >= 0.999 { clipped = true }
}
/// dB SPL from a linear mean-square, with the calibration offset applied.
private func db(_ power: Double) -> Double {
20 * log10(max(power, 1e-20).squareRoot()) + calibration
}
/// Called once per emitted frame; also advances the rolling Leq window.
///
/// Returns a JSON object built by hand. JSONSerialization renders a
/// rounded Double as `-70.120000000000005`, which is both ugly on the
/// wire and noise in a diff — `%.2f` says what a meter can actually
/// resolve and nothing more.
func snapshot() -> String {
ring.append(msNone)
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
let fps = Double(ringCapacity) / 600.0
func rollingLeq(seconds: Double) -> Double {
let want = max(1, Int((seconds * fps).rounded()))
let slice = ring.suffix(want)
guard !slice.isEmpty else { return -200 }
return db(slice.reduce(0, +) / Double(slice.count))
}
return "{\"i\":\(index)"
+ f("fast", db(msFast))
+ f("slow", db(msSlow))
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
+ f("none", db(msNone))
+ f("leq1m", rollingLeq(seconds: 60))
+ f("leq10m", rollingLeq(seconds: 600))
+ f("peakC", db(peakC * peakC))
+ f("peakZ", db(peakZ * peakZ))
+ f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot()))
+ ",\"clip\":\(clipped)}"
}
/// Clears Leq, peaks and the clip flag. The exponential integrator is
/// deliberately left alone — it is a live reading, not an accumulation,
/// and zeroing it would just make the meter dive for a moment.
func reset() {
leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true)
peakC = 0; peakZ = 0; clipped = false
}
}
/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a
/// silent input can never put a non-number on the wire.
func f(_ key: String, _ v: Double) -> String {
",\"\(key)\":" + num(v)
}
func num(_ v: Double) -> String {
guard v.isFinite else { return "-200.00" }
return String(format: "%.2f", max(v, -200))
}
/// One decimal, for band arrays. 479 bands per channel per frame is the
/// bulk of the wire; a second decimal on a bar nobody can read that finely
/// is 15% of the bandwidth for nothing.
func num1(_ v: Double) -> String {
guard v.isFinite else { return "-200" }
return String(format: "%.1f", max(v, -200))
}
/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
final class Spectrum {
private let fftSize: Int
private let log2n: vDSP_Length
private let setup: FFTSetup
private var window: [Float]
private var accum: [Float] // sliding input buffer
private var filled = 0
private let bins: [(Int, Int)]
private let windowPower: Double
init(fs: Double, fftSize: Int) {
self.fftSize = fftSize
log2n = vDSP_Length(log2(Double(fftSize)).rounded())
guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
fail("cannot create FFT setup")
}
setup = s
window = [Float](repeating: 0, count: fftSize)
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM))
accum = [Float](repeating: 0, count: fftSize)
bins = bandBins(fs: fs, fftSize: fftSize)
// Coherent gain of the Hann window, so band levels are not 6 dB low.
windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize)
}
deinit { vDSP_destroy_fftsetup(setup) }
/// Keep the most recent `fftSize` samples, shifting the window along.
/// Buffers larger than the window just overwrite it outright.
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
if n >= fftSize {
accum.withUnsafeMutableBufferPointer { a in
a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize)
}
filled = fftSize
return
}
accum.withUnsafeMutableBufferPointer { a in
let base = a.baseAddress!
// Slide the old tail down, then append the new block.
base.update(from: base + n, count: fftSize - n)
(base + fftSize - n).update(from: samples, count: n)
}
filled = min(fftSize, filled + n)
}
/// Third-octave band levels in dB relative to full scale, plus the
/// calibration offset applied by the caller.
func bands(calibration: Double) -> [Double] {
guard filled >= fftSize else { return bins.map { _ in -200 } }
var windowed = [Float](repeating: 0, count: fftSize)
vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
let half = fftSize / 2
var re = [Float](repeating: 0, count: half)
var im = [Float](repeating: 0, count: half)
var mag = [Float](repeating: 0, count: half)
re.withUnsafeMutableBufferPointer { rp in
im.withUnsafeMutableBufferPointer { ip in
var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
windowed.withUnsafeBytes { raw in
vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2,
&split, 1, vDSP_Length(half))
}
vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half))
}
}
// vDSP's real FFT carries a factor of 2, and zvmags squares it.
let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower)
return bins.map { (lo, hi) in
var power = 0.0
for b in lo...hi { power += Double(mag[b]) }
power *= scale * 2.0 // one-sided spectrum
return 10 * log10(max(power, 1e-20)) + calibration
}
}
}
/* ── args ───────────────────────────────────────────────────────────── */
var wantList = false
var wantSelftest = false
var wantDevice: String? = nil
var wantChannels: [Int] = [1]
var wantCal: [Double] = []
var wantWeighting = Weighting.a
var wantBands = false
var framesPerSecond = 20.0
let argv = CommandLine.arguments
var argIdx = 1
func nextArg(_ flag: String) -> String {
argIdx += 1
guard argIdx < argv.count else { fail("\(flag) needs a value") }
return argv[argIdx]
}
while argIdx < argv.count {
switch argv[argIdx] {
case "--list": wantList = true
case "--selftest": wantSelftest = true
case "--device": wantDevice = nextArg("--device")
case "--channels":
wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") }
case "--cal":
wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
case "--weighting":
let raw = nextArg("--weighting").uppercased()
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
wantWeighting = w
case "--bands": wantBands = true
case "--rate":
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
framesPerSecond = r
default: fail("unknown flag \(argv[argIdx]) — try --list")
}
argIdx += 1
}
/* ── selftest: prove the weighting curves before trusting a reading ─── */
if wantSelftest {
// IEC 61672-1 table 3, nominal A- and C-weighting.
let table: [(Double, Double, Double)] = [
(31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0),
(500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8),
(8000, -1.1, -3.0), (16000, -6.6, -8.5),
]
for fs in [48000.0, 96000.0] {
let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs)
print("— \(Int(fs)) Hz —")
print(" Hz A IEC err C IEC err")
var worstA = 0.0, worstC = 0.0
for (f, wantA, wantC) in table where f < fs / 2 {
let gotA = 20 * log10(cascadeResponse(a, f, fs: fs))
let gotC = 20 * log10(cascadeResponse(c, f, fs: fs))
worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC))
print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f",
f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC))
}
print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC))
}
exit(0)
}
/* ── device selection ───────────────────────────────────────────────── */
let devices = listInputDevices()
if wantList || wantDevice == nil {
if devices.isEmpty { fail("no input-capable audio devices found") }
// stdout, as JSON: the module's /devices route feeds this straight to
// the admin page's device picker.
let payload = devices.map { ["name": $0.name, "uid": $0.uid,
"channels": $0.channels, "sampleRate": $0.sampleRate] }
let data = try! JSONSerialization.data(withJSONObject: ["devices": payload])
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
exit(wantList ? 0 : 2)
}
let query = wantDevice!.lowercased()
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
let matches = exact.count == 1 ? exact
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
guard matches.count == 1 else {
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
}
let device = matches[0]
/* ── capture ────────────────────────────────────────────────────────── */
let engine = AVAudioEngine()
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
var deviceID = device.id
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global, 0, &deviceID,
UInt32(MemoryLayout<AudioDeviceID>.size))
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
let format = engine.inputNode.inputFormat(forBus: 0)
let channelCount = Int(format.channelCount)
let sampleRate = format.sampleRate
guard channelCount > 0, sampleRate > 0 else {
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
+ "(System Settings → Privacy & Security → Microphone)")
}
for ch in wantChannels where ch < 1 || ch > channelCount {
fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)")
}
let calibrated = !wantCal.isEmpty
if calibrated && wantCal.count != wantChannels.count {
fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))")
}
let meters = wantChannels.enumerated().map { (i, ch) in
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
}
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
+ "\(wantWeighting.rawValue)-weighted"
+ (calibrated ? "" : ", UNCALIBRATED"))
// Announced once so the reader knows the band plan without hardcoding it.
let hello: [String: Any] = [
"hello": 1,
"device": device.name,
"sampleRate": sampleRate,
"deviceChannels": channelCount,
"channels": wantChannels,
"weighting": wantWeighting.rawValue,
"calibrated": calibrated,
// Centres and the index of the 1 kHz band, so a tile can fold to any
// banding without hardcoding the plan.
"bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [],
"bandN0": bandN0,
"bandsPerOctave": 48,
]
let stdoutHandle = FileHandle.standardOutput
func emitLine(_ json: String) {
do {
try stdoutHandle.write(contentsOf: Data((json + "\n").utf8))
} catch { exit(0) } // downstream pipe closed — nothing to say
}
if let data = try? JSONSerialization.data(withJSONObject: hello),
let line = String(data: data, encoding: .utf8) {
emitLine(line)
}
// A reset is asked for on stdin (one line, "reset") rather than a signal, so
// the listener can clear Leq and peaks without restarting capture and losing
// the filters' settling.
let resetQueue = DispatchQueue(label: "spl-capture.reset")
var resetRequested = false
DispatchQueue.global().async {
while let line = readLine(strippingNewline: true) {
if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" {
resetQueue.sync { resetRequested = true }
}
}
}
let tapQueue = DispatchQueue(label: "spl-capture.tap")
engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
let frames = Int(buffer.frameLength)
guard frames > 0, let floats = buffer.floatChannelData else { return }
tapQueue.sync {
for (i, ch) in wantChannels.enumerated() {
let src = floats[ch - 1] // tap data is per-channel, not interleaved
meters[i].push(src, frames)
if wantBands { spectra[i].push(src, frames) }
}
}
}
do { try engine.start() } catch {
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
+ "approve microphone access for the launching app and retry")
}
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
timer.schedule(deadline: .now() + 1.0 / framesPerSecond,
repeating: 1.0 / framesPerSecond)
timer.setEventHandler {
var wantReset = false
resetQueue.sync { wantReset = resetRequested; resetRequested = false }
var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))"
tapQueue.sync {
if wantReset { meters.forEach { $0.reset() } }
line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]"
if wantBands {
let sets = spectra.enumerated().map { (i, s) -> String in
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
.map(num1).joined(separator: ",") + "]"
}
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
}
}
emitLine(line + "}")
}
timer.resume()
signal(SIGINT) { _ in exit(0) }
signal(SIGTERM) { _ in exit(0) }
RunLoop.main.run()
+239
View File
@@ -0,0 +1,239 @@
'use strict';
/**
* Sound level listener — owns the native capture child.
*
* Spawns spl-capture (compiled from spl-capture.swift beside it) 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.
*
* Reports via onUpdate:
*
* { 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: see ensureBinary for what that cost the first time it was wrong.
*
* The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a
* bare launchd job gets SILENT ZEROS from every input, with no error
* anywhere. Here that shows up as a meter pinned at -200. Launch from a
* logged-in GUI context and approve the microphone prompt once.
*/
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) —
retrying every 5 s would only fill the log with the same sentence. */
const BAD_CONFIG_RETRY_MS = 30000;
/** No frame for this long means the child is alive but not delivering. */
const STALL_MS = 3000;
/**
* @param {{device:string, channels:number[], weighting:string,
* bands:boolean, calibration:number[], moduleDir:string,
* log:Function, onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void, reset:()=>void}}
*/
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;
let restartTimer = null;
let stallTimer = null;
let startedAt = 0;
let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0;
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
/**
* Build the capture tool if it is missing, WITHOUT blocking.
*
* This used to be a spawnSync, copied from the LTC listener where the
* source is a third the size and builds in a few seconds. Here it took
* nearly two minutes on the production Mac and froze the entire ProdDash
* server for every second of it — one module's optional helper took the
* whole dashboard down, then did it again on every retry. Nothing that
* runs while a service might be on is allowed to hold the event loop.
*
* The build is a fallback for a module installed from the catalog on a
* machine with a Swift toolchain. The normal path is a binary built
* alongside the app, or copied from any same-architecture Mac.
*
* @returns {string} '' when the binary is ready, otherwise what to say.
*/
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return 'Building the capture tool — this takes a minute or two';
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return 'Building the capture tool — this takes a minute or two';
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
}
function armStallWatch() {
clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
if (stopped || !child) return;
if (Date.now() - lastFrameWall >= STALL_MS) {
log('no frames for 3 s — restarting capture');
try { child.kill('SIGTERM'); } catch { /* already gone */ }
}
}, STALL_MS);
}
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 (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
// No averaging or banding flag: the child computes every averaging and
// the finest banding regardless, so tiles can differ without a restart.
const args = ['--device', device, '--channels', channels.join(','),
'--weighting', weighting, '--rate', '20'];
if (bands) args.push('--bands');
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
startedAt = Date.now();
meta = null;
let stderrTail = '';
let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
stderrTail = (stderrTail + text).slice(-500);
});
child.stdout.setEncoding('utf8');
child.stdout.on('data', (text) => {
buf += text;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
let obj;
try { obj = JSON.parse(line); } catch { continue; }
if (obj.hello) {
meta = obj;
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
+ `${obj.weighting}-weighted`
+ (obj.calibrated ? '' : ' (uncalibrated)'));
continue;
}
if (!obj.ch) continue;
lastFrameWall = Date.now();
armStallWatch();
onUpdate({ frame: obj, meta });
}
// A run of junk with no newline would grow forever; the child never
// emits a line this long, so anything that does is a bug upstream.
if (buf.length > 65536) buf = '';
});
child.on('error', (err) => {
onUpdate({ error: `cannot run spl-capture: ${err.message}` });
child = null;
scheduleRestart(BAD_CONFIG_RETRY_MS);
});
child.on('exit', (code, signal) => {
child = null;
clearTimeout(stallTimer);
if (stopped) return;
const ranFor = Date.now() - startedAt;
const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`;
// Exiting almost immediately means it never got as far as capturing —
// an unknown device, an out-of-range channel, a denied microphone.
// Those do not fix themselves, so back off hard and say why.
if (ranFor < 2000) {
onUpdate({ error: why });
scheduleRestart(BAD_CONFIG_RETRY_MS);
} else {
log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`);
scheduleRestart(RESTART_MS);
}
});
armStallWatch();
}
start();
return {
/** Clear Leq and peak hold without restarting capture — the filters keep
their settling, so the live number never dips. */
reset() {
if (child && child.stdin.writable) {
try { child.stdin.write('reset\n'); } catch { /* child going away */ }
}
},
stop() {
stopped = true;
clearTimeout(restartTimer); restartTimer = null;
clearTimeout(stallTimer); stallTimer = null;
if (child) {
try { child.kill('SIGTERM'); } catch { /* already gone */ }
child = null;
}
},
};
}
module.exports = { createSplListener };
+148
View File
@@ -0,0 +1,148 @@
/* SPL Meter — every colour comes from the shell's variables so themes and
per-tile tints reach in. Nothing here is hardcoded except the canvas
heat ramp, which client.js owns because it has to interpolate. */
[data-module="spl-meter"] .spl {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── readout ──────────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-readout {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25em;
min-height: 0;
padding: 0.25rem;
}
[data-module="spl-meter"] .spl-value {
display: flex;
align-items: baseline;
gap: 0.2em;
line-height: 1;
color: var(--text);
}
/* A first-paint size only. client.js measures the text and sets the real
one inline, because CSS has to assume a digit count and gets it wrong the
moment the reading needs a sixth character: the coefficient that fills a
tile for "-45.9" pushes "-100.5" straight out through the side. */
[data-module="spl-meter"] .spl-num {
font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem);
font-variant-numeric: tabular-nums;
font-weight: 600;
letter-spacing: -0.02em;
}
[data-module="spl-meter"] .spl-unit {
font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem);
color: var(--muted);
}
[data-module="spl-meter"] .spl-sub {
font-size: 0.7rem;
color: var(--muted);
text-align: center;
line-height: 1.3;
}
[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); }
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
/* ── level bars ───────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-bars {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
padding: 0.4rem;
min-height: 0;
overflow-y: auto;
}
[data-module="spl-meter"] .spl-bar {
display: grid;
grid-template-columns: 1.4em 1fr 3.4em;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; }
[data-module="spl-meter"] .spl-bar-track {
position: relative;
height: 0.65rem;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--border) 60%, transparent);
overflow: hidden;
}
[data-module="spl-meter"] .spl-bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: inherit;
background: var(--accent);
transition: width 60ms linear;
}
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
/* Peak hold sits on the track rather than inside the fill, so it stays
visible when the bar falls away underneath it. */
[data-module="spl-meter"] .spl-bar-peak {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--text);
opacity: 0.75;
}
[data-module="spl-meter"] .spl-bar-value {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text);
}
[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); }
/* ── canvas tiles ─────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-canvas {
flex: 1;
display: block;
width: 100%;
min-height: 0;
border-radius: 0.25rem;
}
/* The readout scales off both of the tile's dimensions, so the container
has to be sized on both axes — inline-size alone leaves cqh undefined. */
[data-module="spl-meter"] .spl { container-type: size; }
/* The sub-line is the first thing to go when the tile gets short: the
number is the point, and a clipped caption under it is worse than none. */
[data-module="spl-meter"] .spl-sub {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container (max-height: 90px) {
[data-module="spl-meter"] .spl-sub { display: none; }
}
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.4.0", "version": "1.9.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+68
View File
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
/* one swatch per preset: a miniature dashboard (page, a tile on it, the /* one swatch per preset: a miniature dashboard (page, a tile on it, the
accent as a status dot) and the name; the current one wears the accent */ accent as a status dot) and the name; the current one wears the accent */
/* ── a collapsible admin section ─────────────────────────────────────
The pattern for any section of this page that folds. The markup is a
<details class="admin-fold"> holding the section's contents, headed by a
<summary class="admin-h1 admin-fold-title">: the title keeps the same
type as every other section heading — a <summary> would otherwise come
out at the normal weight — and the caret follows the words, so a folded
section still reads as a heading first. The whole row is the hit target. */
details.admin-fold > summary.admin-fold-title {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
user-select: none;
}
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
/* after the title, not before it — the heading reads first, the caret says
there is more behind it */
details.admin-fold > summary.admin-fold-title::after {
content: "";
width: 0; height: 0;
border-left: 6px solid var(--muted);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform 0.15s;
}
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
.theme-swatches { .theme-swatches {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
@@ -512,3 +542,41 @@ a.btn { text-decoration: none; }
color: var(--accent); color: var(--accent);
} }
#theme-status { margin-top: 4px; } #theme-status { margin-top: 4px; }
/* ── the admin PIN ── */
/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */
.pin-input {
font-variant-numeric: tabular-nums;
letter-spacing: 0.35em;
text-align: center;
max-width: 9em;
}
.pin-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.pin-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.pin-field[hidden] { display: none; }
.pin-field input {
padding: 8px 10px;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.pin-field input:focus { outline: none; border-color: var(--accent); }
.pin-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
+110 -3
View File
@@ -2,8 +2,10 @@
Everything here is server-wide: module enable/disable and admin config Everything here is server-wide: module enable/disable and admin config
(written to config/modules.json, module re-inited on save), plus named (written to config/modules.json, module re-inited on save), plus named
layout management. Guarded by the shared passcode when one is set. */ layout management. Behind the admin PIN when one is set — a shell setting of
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
const securitySection = document.getElementById('security-section');
const loginCard = document.getElementById('login-card'); const loginCard = document.getElementById('login-card');
const loginStatus = document.getElementById('login-status'); const loginStatus = document.getElementById('login-status');
const passcodeInput = document.getElementById('passcode'); const passcodeInput = document.getElementById('passcode');
@@ -62,20 +64,30 @@ function showLogin() {
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true; themeSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus(); passcodeInput.focus();
} }
let loggingIn = false;
async function login() { async function login() {
if (loggingIn) return;
loggingIn = true;
loginStatus.textContent = ''; loginStatus.textContent = '';
loginStatus.className = 'admin-status'; loginStatus.className = 'admin-status';
try { try {
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) }); await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
passcodeInput.value = ''; passcodeInput.value = '';
loginCard.hidden = true; loginCard.hidden = true;
await loadState(); await loadState();
} catch (e) { } catch (e) {
passcodeInput.value = '';
loginStatus.textContent = e.message; loginStatus.textContent = e.message;
loginStatus.className = 'admin-status error'; loginStatus.className = 'admin-status error';
passcodeInput.focus();
} finally {
loggingIn = false;
} }
} }
@@ -83,6 +95,82 @@ document.getElementById('login-btn').addEventListener('click', login);
passcodeInput.addEventListener('keydown', (e) => { passcodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login(); if (e.key === 'Enter') login();
}); });
// Six digits is the whole PIN, so there is nothing left to press.
passcodeInput.addEventListener('input', () => {
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
if (passcodeInput.value.length === 6) login();
});
/* ── the admin PIN ───────────────────────────────────────────────────── */
const pinCurrentField = document.getElementById('pin-current-field');
const pinCurrent = document.getElementById('pin-current');
const pinNew = document.getElementById('pin-new');
const pinRepeat = document.getElementById('pin-repeat');
const pinSaveBtn = document.getElementById('pin-save');
const pinClearBtn = document.getElementById('pin-clear');
const pinStatus = document.getElementById('pin-status');
const pinStateEl = document.getElementById('pin-state');
for (const input of [pinCurrent, pinNew, pinRepeat]) {
input.addEventListener('input', () => {
input.value = input.value.replace(/\D/g, '').slice(0, 6);
});
}
/** Paint the section for "no PIN yet" or "one is set". */
function renderPin(state) {
const set = Boolean(state.pinSet);
const fromEnv = Boolean(state.pinFromEnv);
securitySection.hidden = false;
pinCurrentField.hidden = !set;
pinClearBtn.hidden = !set;
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
pinStateEl.textContent = fromEnv
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
: set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
}
function pinMessage(text, bad = false) {
pinStatus.textContent = text;
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
}
async function savePin(clearing = false) {
pinStatus.textContent = '';
pinStatus.className = 'admin-status';
const current = pinCurrent.value;
const next = clearing ? '' : pinNew.value;
if (!clearing) {
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
}
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
return pinMessage('Enter the current PIN to change it.', true);
}
pinSaveBtn.disabled = true;
pinClearBtn.disabled = true;
try {
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
await loadState();
} catch (e) {
pinMessage(e.message, true);
} finally {
pinSaveBtn.disabled = false;
pinClearBtn.disabled = false;
}
}
pinSaveBtn.addEventListener('click', () => savePin(false));
pinClearBtn.addEventListener('click', () => {
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
});
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
/* ── module cards ───────────────────────────────────────────────────── */ /* ── module cards ───────────────────────────────────────────────────── */
@@ -821,7 +909,25 @@ async function loadLayouts() {
else toast(e.message, true); else toast(e.message, true);
} }
}); });
row.append(name, meta, rename, del); // The copy the last save replaced, one back. Restoring swaps the two,
// so a restore is itself one restore away from being undone.
const restore = document.createElement('button');
restore.className = 'btn';
restore.textContent = 'Restore previous';
restore.title = 'Put back the copy the last save replaced';
restore.hidden = !layout.hasPrevious;
restore.addEventListener('click', async () => {
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
try {
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
toast('Previous version restored');
loadLayouts();
} catch (e) {
if (e.authRequired) showLogin();
else toast(e.message, true);
}
});
row.append(name, meta, restore, rename, del);
layoutsList.appendChild(row); layoutsList.appendChild(row);
} }
} }
@@ -856,6 +962,7 @@ async function loadState() {
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
renderPin(state);
loadTheme(); loadTheme();
loadLayouts(); loadLayouts();
loadCatalog(false); loadCatalog(false);
+37 -5
View File
@@ -18,10 +18,11 @@
<main id="admin-main"> <main id="admin-main">
<section class="admin-card" id="login-card" hidden> <section class="admin-card" id="login-card" hidden>
<h2>Admin passcode</h2> <h2>Admin PIN</h2>
<p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p> <p class="admin-copy">This page changes the server-wide configuration for every dashboard in the building.</p>
<div class="login-row"> <div class="login-row">
<input type="password" id="passcode" placeholder="Passcode" autocomplete="current-password"> <input type="password" id="passcode" class="pin-input" inputmode="numeric" autocomplete="off"
maxlength="6" placeholder="••••••" aria-label="Admin PIN">
<button class="btn primary" id="login-btn">Unlock</button> <button class="btn primary" id="login-btn">Unlock</button>
</div> </div>
<div class="admin-status" id="login-status"></div> <div class="admin-status" id="login-status"></div>
@@ -40,9 +41,11 @@
</section> </section>
<section id="theme-section" hidden> <section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1> <details class="admin-fold">
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div> <summary class="admin-h1 admin-fold-title">Theme</summary>
<div class="admin-status" id="theme-status"></div> <div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</details>
</section> </section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
@@ -65,6 +68,35 @@
<h1 class="admin-h1">Saved layouts</h1> <h1 class="admin-h1">Saved layouts</h1>
<div id="layouts-list"></div> <div id="layouts-list"></div>
</section> </section>
<section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p>
<div class="admin-card">
<div class="pin-form">
<label class="pin-field" id="pin-current-field" hidden>
<span>Current PIN</span>
<input type="password" id="pin-current" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="••••••">
</label>
<label class="pin-field">
<span id="pin-new-label">New PIN</span>
<input type="password" id="pin-new" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<label class="pin-field">
<span>Repeat</span>
<input type="password" id="pin-repeat" class="pin-input" inputmode="numeric"
autocomplete="off" maxlength="6" placeholder="6 digits">
</label>
<div class="pin-actions">
<button class="btn primary" id="pin-save">Set PIN</button>
<button class="btn" id="pin-clear" hidden>Turn off</button>
</div>
</div>
<div class="admin-status" id="pin-status"></div>
</div>
</section>
</main> </main>
<div id="toast" hidden></div> <div id="toast" hidden></div>
+8
View File
@@ -19,6 +19,14 @@
<span class="spacer"></span> <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">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l3-3 3 3"/>
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
<path d="M4 14h16"/>
</svg>
</button>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen"> <button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/> <path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
+519 -105
View File
@@ -5,7 +5,18 @@
settings) autosaves to this browser's localStorage — every browser is fully settings) autosaves to this browser's localStorage — every browser is fully
independent. Modules render inside tile bodies and never touch the shell. */ independent. Modules render inside tile bodies and never touch the shell. */
const COLS = 12; /* ── grid density ─────────────────────────────────────────────────────
How many columns the board is cut into. Twelve is the default; a finer
grid lets tiles be smaller and more of them fit across (a 4K wall), a
coarser one suits a small screen read close up. It is this browser's
choice — two displays at one location may want different densities — so
it saves with the layout, local and named, and a named layout comes back
at the density it was arranged on. Row height follows the column width, so
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
const GRID_DEFAULT = 12;
const CELL_H_DEFAULT = 72; // px per row at the default density
let COLS = GRID_DEFAULT;
const LS_LAYOUT = 'proddash:layout'; const LS_LAYOUT = 'proddash:layout';
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
@@ -16,14 +27,27 @@ const toastEl = document.getElementById('toast');
/* ── tiny helpers ───────────────────────────────────────────────────── */ /* ── tiny helpers ───────────────────────────────────────────────────── */
let toastTimer = null; let toastTimer = null;
function toast(msg, isError = false) { function toast(msg, isError = false, { action = null, ms } = {}) {
toastEl.textContent = msg; toastEl.textContent = msg;
if (action) {
// One button, one verb — "Undo" — for a change worth taking back.
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = action.label;
btn.addEventListener('click', () => {
clearTimeout(toastTimer);
toastEl.hidden = true;
action.onClick();
});
toastEl.appendChild(btn);
}
toastEl.classList.toggle('error', isError); toastEl.classList.toggle('error', isError);
toastEl.hidden = false; toastEl.hidden = false;
clearTimeout(toastTimer); clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200));
} }
const autoHideBtn = document.getElementById('autohide-btn');
const modalBackdrop = document.getElementById('modal-backdrop'); const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal'); const modalEl = document.getElementById('modal');
@@ -69,6 +93,41 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
}); });
} }
/** A yes-or-no question in the same window: resolves true for the OK button. */
function confirmModal({ title, body = '', okLabel = 'OK' }) {
return new Promise((resolve) => {
modalEl.innerHTML = '';
const h = document.createElement('h2');
h.textContent = title;
const copy = document.createElement('p');
copy.className = 'modal-copy';
copy.textContent = body;
const actions = document.createElement('div');
actions.className = 'actions';
const cancel = document.createElement('button');
cancel.className = 'btn';
cancel.textContent = 'Cancel';
const ok = document.createElement('button');
ok.className = 'btn primary';
ok.textContent = okLabel;
actions.append(cancel, ok);
modalEl.append(h, copy, actions);
modalBackdrop.hidden = false;
ok.focus();
const done = (result) => {
modalBackdrop.hidden = true;
modalEl.innerHTML = '';
resolve(result);
};
cancel.addEventListener('click', () => done(false));
ok.addEventListener('click', () => done(true));
modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true });
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) done(false);
}, { once: true });
});
}
/* ── dropdown menus ─────────────────────────────────────────────────── */ /* ── dropdown menus ─────────────────────────────────────────────────── */
/* A menu opens the moment its button is clicked, drawn from what the page /* A menu opens the moment its button is clicked, drawn from what the page
@@ -161,18 +220,43 @@ async function loadRegistry() {
let tiles = []; let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance } const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
/* Whether tile title bars fold away until you point at them. It is part of
how a dashboard is arranged rather than a preference of this browser, so
it saves with the layout — including a named layout on the server — and a
fresh dashboard starts with the bars showing. Fullscreen folds every bar
away regardless; this is the windowed equivalent, asked for by hand. */
let autoHideHeads = 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). */
const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup';
const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
function saveLayout() { function saveLayout() {
try { try {
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles })); const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
const current = localStorage.getItem(LS_LAYOUT);
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
localStorage.setItem(LS_LAYOUT, next);
} catch { /* private mode / full — layout just won't persist */ } } catch { /* private mode / full — layout just won't persist */ }
} }
function loadLayoutFromStorage() { function loadLayoutFromStorage() {
try { for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) {
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null'); try {
if (raw && Array.isArray(raw.tiles)) return raw.tiles; const raw = JSON.parse(localStorage.getItem(key) || 'null');
} catch { /* corrupted — start fresh */ } if (raw && Array.isArray(raw.tiles)) {
return []; // A layout that would not parse is not a reason for an empty wall.
if (key === LS_LAYOUT_BACKUP) {
toast('This browser’s layout was unreadable — the last good copy is back', true);
try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ }
}
return raw;
}
} catch { /* unreadable — try the copy behind it */ }
}
return { tiles: [] };
} }
function sanitizeTile(t) { function sanitizeTile(t) {
@@ -185,7 +269,7 @@ function sanitizeTile(t) {
w, w,
h: Math.max(1, Number(t.h) || 2), h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {}, settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
headHidden: Boolean(t.headHidden), headPinned: Boolean(t.headPinned),
// set when the tile was added from a module's tile list (multi-tile modules) // set when the tile was added from a module's tile list (multi-tile modules)
variant: t.variant ? String(t.variant) : '', variant: t.variant ? String(t.variant) : '',
title: t.title ? String(t.title) : '', title: t.title ? String(t.title) : '',
@@ -202,6 +286,87 @@ function newId() {
/* ── grid geometry & collision ──────────────────────────────────────── */ /* ── grid geometry & collision ──────────────────────────────────────── */
function normalizeGridCols(value) {
const n = Number(value);
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
}
function cellHeightFor(cols) {
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
}
/** Cut the board into `cols` columns. Tiles keep their grid units — the
board gets denser or coarser and everything on it changes size with it.
A tile now past the right edge is pulled back in, and one that would then
sit on another moves to the first free spot instead. */
function setGridCols(value, { save = true } = {}) {
const next = normalizeGridCols(value);
const changed = next !== COLS;
COLS = next;
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
if (changed) {
// A coarser board may not have room for every tile where it was. Settle
// them in reading order: pull a tile past the right edge back in, and
// give one that would then land on a tile already settled the first free
// spot instead — tiles never overlap here, any more than under a drag.
const settled = [];
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
tile.w = Math.max(1, Math.min(COLS, tile.w));
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
settled.push(tile);
applyRect(tile);
notifyResize(tile);
}
rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
}
if (save) saveLayout();
}
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
closing the menu, so the board can be watched changing behind it. */
function gridStepper() {
const row = document.createElement('div');
row.className = 'menu-stepper';
const label = document.createElement('span');
label.className = 'menu-stepper-label';
label.textContent = 'Grid Size';
const minus = document.createElement('button');
minus.type = 'button';
minus.className = 'menu-stepper-btn';
minus.setAttribute('data-tip', 'Coarser');
minus.innerHTML = STEP_MINUS_SVG;
const value = document.createElement('span');
value.className = 'menu-stepper-value';
const plus = document.createElement('button');
plus.type = 'button';
plus.className = 'menu-stepper-btn';
plus.setAttribute('data-tip', 'Finer');
plus.innerHTML = STEP_PLUS_SVG;
const paint = () => {
const i = GRID_STEPS.indexOf(COLS);
value.textContent = String(COLS);
minus.disabled = i <= 0;
plus.disabled = i >= GRID_STEPS.length - 1;
};
const step = (dir) => {
const i = GRID_STEPS.indexOf(COLS) + dir;
if (i < 0 || i >= GRID_STEPS.length) return;
setGridCols(GRID_STEPS[i]);
paint();
};
minus.addEventListener('click', () => step(-1));
plus.addEventListener('click', () => step(1));
paint();
row.append(label, minus, value, plus);
return row;
}
function cellMetrics() { function cellMetrics() {
const style = getComputedStyle(grid); const style = getComputedStyle(grid);
const gapRaw = parseFloat(style.columnGap); const gapRaw = parseFloat(style.columnGap);
@@ -224,6 +389,17 @@ function isFree(rect, excludeId) {
return isFreeExcept(rect, [excludeId]); return isFreeExcept(rect, [excludeId]);
} }
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
function spotAmong(w, h, others) {
for (let y = 0; y < 400; y += 1) {
for (let x = 0; x <= COLS - w; x += 1) {
const rect = { x, y, w, h };
if (!others.some((o) => overlaps(rect, o))) return { x, y };
}
}
return { x: 0, y: 0 };
}
/** First free spot scanning top-to-bottom, left-to-right. */ /** First free spot scanning top-to-bottom, left-to-right. */
function findSpot(w, h) { function findSpot(w, h) {
for (let y = 0; y < 200; y += 1) { for (let y = 0; y < 200; y += 1) {
@@ -249,11 +425,116 @@ function minSizeOf(tile) {
}; };
} }
/* ── title bars that fold away ──────────────────────────────────────── */
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
on and this tile is not pinned, or the screen is in fullscreen, which folds
every bar for watching whatever the tiles say. Off is never gone — the top
edge of the tile brings it back for as long as the pointer is there. */
/** How close to the tile's top edge the pointer opens a folded-away bar. */
const HEAD_REVEAL_PX = 22;
/** How far down it may wander before that bar folds away again (bar + a little). */
const HEAD_HOLD_PX = 38;
function headIsOff(tile) {
if (document.body.classList.contains('fullscreen')) return true;
return autoHideHeads && !tile.headPinned;
}
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const off = headIsOff(tile);
el.classList.toggle('head-off', off);
if (!off) el.classList.remove('head-peek');
}
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
function applyAllHeadStates() {
for (const tile of tiles) applyHeadState(tile);
}
function setAutoHideHeads(on, { save = true } = {}) {
autoHideHeads = Boolean(on);
document.body.classList.toggle('autohide-heads', autoHideHeads);
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
applyAllHeadStates();
if (save) saveLayout();
}
/* ── tile DOM ───────────────────────────────────────────────────────── */ /* ── tile DOM ───────────────────────────────────────────────────────── */
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>'; const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>'; const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
/* ── what a saved tile can be shown as ─────────────────────────────────
A layout keeps every tile it was given; the board shows the ones whose
module is installed and enabled and — for a tile added from a picker
entry — whose entry (or an alias of it) the module offers right now. The
rest keep their place and draw nothing, and appear the moment the
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. */
function tileIsOffered(tile) {
const man = registry.get(tile.module);
if (!man) return false;
const variant = String(tile.variant || '');
if (!variant) return true; // the plain module tile — the module says what that means
const entries = Array.isArray(man.tiles) ? man.tiles : [];
// No list at all is a module whose tiles() did not answer in time (the
// server falls back to the classic single entry): mount, and let it sort
// its own variant out rather than blank the board over a slow answer.
if (!entries.length) return true;
return entries.some((e) => String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant)));
}
/** Show tiles that have become offered; fold away tiles that have stopped being. */
function reconcileOfferedTiles() {
let changed = false;
for (const tile of tiles) {
const shown = tileEls.has(tile.id);
const offered = tileIsOffered(tile);
if (offered && !shown) {
buildTile(tile);
changed = true;
} else if (!offered && shown) {
unmountModule(tile);
tileEls.get(tile.id)?.el.remove();
tileEls.delete(tile.id);
changed = true;
}
}
if (changed) rebuildDividers();
scheduleOfferedRecheck();
}
/* A module's entry list can change without anyone saving admin config: PCO
Plan comes back and, a few seconds later, the timers module is offering
its timers again. No event marks that moment, so while any tile is folded
away the registry is re-read every few seconds until nothing is. It costs
one GET while something is hidden and nothing at all otherwise. */
const OFFERED_RECHECK_MS = 6000;
let offeredRecheck = 0;
function scheduleOfferedRecheck() {
clearTimeout(offeredRecheck);
if (!tiles.some((t) => !tileEls.has(t.id))) return;
offeredRecheck = setTimeout(async () => {
offeredRecheck = 0;
if (await loadRegistry()) reconcileOfferedTiles();
else scheduleOfferedRecheck();
}, OFFERED_RECHECK_MS);
}
/** 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);
}
function buildTile(tile) { function buildTile(tile) {
const man = registry.get(tile.module); const man = registry.get(tile.module);
@@ -278,7 +559,34 @@ function buildTile(tile) {
const controls = document.createElement('span'); const controls = document.createElement('span');
controls.className = 'tile-controls'; controls.className = 'tile-controls';
head.append(dot, title, controls); /* Pinned tiles keep their bar through auto-hide. The switch sits right
beside the label rather than out with the actions, because it is about
this bar rather than about the tile's contents. */
const pin = document.createElement('button');
pin.className = 'tile-btn pin';
pin.innerHTML = PIN_SVG;
const paintPin = () => {
const on = Boolean(tile.headPinned);
pin.classList.toggle('is-pinned', on);
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
// Instant tooltips read `title` the first time and `data-tip` after.
pin.title = on ? 'Unpin bar' : 'Pin bar';
pin.setAttribute('data-tip', pin.title);
};
paintPin();
pin.addEventListener('click', (e) => {
e.stopPropagation();
tile.headPinned = !tile.headPinned;
paintPin();
applyHeadState(tile, el);
saveLayout();
});
// Keeps the label hugging the pin however wide the tile gets.
const gap = document.createElement('span');
gap.className = 'tile-head-gap';
head.append(dot, title, pin, gap, controls);
// Every tile has something to set: its own settings when the module // Every tile has something to set: its own settings when the module
// offers any, and the tint either way. // offers any, and the tint either way.
@@ -307,45 +615,47 @@ function buildTile(tile) {
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'tile-body'; body.className = 'tile-body';
const resize = document.createElement('div'); /* The rim: both bottom corners resize in two directions, the bottom edge
resize.className = 'tile-resize'; moves that edge (trading rows with tiles below), and the other three
resize.title = 'Drag to resize'; move only themselves — the top edge changes where the tile starts, the
sides how wide it is — each stopping where a neighbour or the tile's own
minimum says it must. None of them draw anything: the cursor over an
edge is what says it can be dragged, as on a window. */
const grip = (cls) => {
const gripEl = document.createElement('div');
gripEl.className = cls;
return gripEl;
};
const resize = grip('tile-resize');
const resizeLeft = grip('tile-resize-left');
const resizeBottom = grip('tile-resize-bottom');
const edgeTop = grip('tile-resize-top');
const edgeRight = grip('tile-resize-right');
const edgeLeft = grip('tile-resize-side');
const resizeLeft = document.createElement('div'); // A folded-away bar is still reachable: point at the tile's top edge and it
resizeLeft.className = 'tile-resize-left'; // slides in over the content, where its buttons click and its whole width
resizeLeft.title = 'Drag to resize'; // drags the tile exactly as when it is showing.
el.addEventListener('pointermove', (ev) => {
// pill on the bottom edge: moves that edge (trading rows with tiles below) if (!headIsOff(tile)) return;
const resizeBottom = document.createElement('div'); // Mid-drag the tile moves under the pointer, and mid-resize the edges do
resizeBottom.className = 'tile-resize-bottom'; // — neither is a moment to take the bar away.
resizeBottom.title = 'Drag to adjust height'; if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
const y = ev.clientY - el.getBoundingClientRect().top;
// notch on the title bar's bottom edge: hides/shows the bar const shown = el.classList.contains('head-peek');
const notch = document.createElement('button'); // It takes less to hold the bar open than to open it, so a bar that
notch.className = 'tile-notch'; // appears under the pointer cannot flicker straight back out.
notch.title = 'Hide / show the title bar'; if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
notch.addEventListener('click', (e) => { else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
e.stopPropagation();
// In fullscreen the bars are all folded away for watching; the notch
// peeks one open for a moment without touching what this tile saved.
if (document.body.classList.contains('fullscreen')) {
peekHead(tile, el, !el.classList.contains('head-peek'));
return;
}
tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden);
saveLayout();
}); });
// A peeked title bar stays while the pointer is on its tile, and starts
// counting down as soon as it leaves.
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
el.addEventListener('pointerleave', () => { el.addEventListener('pointerleave', () => {
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el); if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
el.classList.remove('head-peek');
}); });
el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
el.classList.toggle('head-hidden', Boolean(tile.headHidden)); applyHeadState(tile, el);
grid.appendChild(el); grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null }; const entry = { el, body, dot, titleEl: title, controls, instance: null };
@@ -356,6 +666,9 @@ function buildTile(tile) {
wireResize(tile, el, resize, false); wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true); wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom); wireBottomResize(tile, el, resizeBottom);
wireEdgeResize(tile, el, edgeTop, 'n');
wireEdgeResize(tile, el, edgeRight, 'e');
wireEdgeResize(tile, el, edgeLeft, 'w');
mountModule(tile); mountModule(tile);
return entry; return entry;
@@ -369,7 +682,7 @@ function refreshEmptyState() {
function wireDrag(tile, el, handle) { function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => { handle.addEventListener('pointerdown', (ev) => {
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return; if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return; if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
@@ -486,6 +799,80 @@ function wireResize(tile, el, handle, fromLeft) {
}); });
} }
/* ── drag the top, left or right edge ───────────────────────────────── */
/* One edge moves and the opposite one stays where it is, so the tile grows
or shrinks into the space beside it. Unlike the bottom-edge pill these do
not trade rows or columns with a neighbour: the edge simply stops when it
meets one, which is what "resize this tile" should mean. */
function wireEdgeResize(tile, el, handle, edge) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startY = tile.y;
const startW = tile.w;
const startH = tile.h;
const min = minSizeOf(tile);
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
let cand;
if (edge === 'n') {
// the top edge moves; the bottom edge stays put
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
} else if (edge === 'w') {
// the left edge moves; the right edge stays put
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
} else {
cand = {
x: tile.x,
y: tile.y,
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
h: tile.h,
};
}
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
if (!isFree(cand, tile.id)) return;
tile.x = cand.x;
tile.y = cand.y;
tile.w = cand.w;
tile.h = cand.h;
applyRect(tile);
changed = true;
notifyResize(tile);
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */ /* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit /* The pill on a tile's bottom edge moves that edge. When other tiles sit
@@ -588,6 +975,7 @@ function rebuildDividers() {
for (const L of tiles) { for (const L of tiles) {
for (const R of tiles) { for (const R of tiles) {
if (L === R || L.x + L.w !== R.x) continue; if (L === R || L.x + L.w !== R.x) continue;
if (!tileEls.has(L.id) || !tileEls.has(R.id)) continue;
const top = Math.max(L.y, R.y); const top = Math.max(L.y, R.y);
const bottom = Math.min(L.y + L.h, R.y + R.h); const bottom = Math.min(L.y + L.h, R.y + R.h);
if (bottom <= top) continue; if (bottom <= top) continue;
@@ -613,7 +1001,6 @@ function seamPairsAt(boundary) {
function buildDivider(L, R, top, bottom) { function buildDivider(L, R, top, bottom) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'tile-divider'; el.className = 'tile-divider';
el.title = 'Drag to resize this column';
const place = () => { const place = () => {
const m = cellMetrics(); const m = cellMetrics();
@@ -626,14 +1013,6 @@ function buildDivider(L, R, top, bottom) {
const boundary = () => L.x + L.w; const boundary = () => L.x + L.w;
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el); const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
// Before anything is dragged, show which seams travel with this one.
el.addEventListener('pointerenter', () => {
if (document.body.classList.contains('fullscreen')) return;
for (const other of groupEls()) other.classList.add('linked');
});
el.addEventListener('pointerleave', unlink);
el.addEventListener('pointerdown', (ev) => { el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
@@ -689,7 +1068,6 @@ function buildDivider(L, R, top, bottom) {
el.removeEventListener('pointerup', onUp); el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointercancel', onUp);
for (const other of group) other.classList.remove('active'); for (const other of group) other.classList.remove('active');
unlink();
if (changed) saveLayout(); if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed rebuildDividers(); // adjacencies may have changed
}; };
@@ -715,10 +1093,14 @@ function addTile(moduleId, entry = null) {
toast(`${entry.name} is already on this dashboard`); toast(`${entry.name} is already on this dashboard`);
return; return;
} }
// Sizes are declared for the default twelve-column grid; scaled so a new
// tile lands at about the size its author meant, whatever the density here.
const scale = COLS / GRID_DEFAULT;
const defaultSize = entry?.defaultSize || man?.defaultSize; const defaultSize = entry?.defaultSize || man?.defaultSize;
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
const size = { const size = {
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)), w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
h: Math.max(1, defaultSize?.h || 3), h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
}; };
const spot = findSpot(size.w, size.h); const spot = findSpot(size.w, size.h);
const settings = {}; const settings = {};
@@ -727,7 +1109,7 @@ function addTile(moduleId, entry = null) {
if (spec && 'default' in spec) settings[key] = spec.default; if (spec && 'default' in spec) settings[key] = spec.default;
} }
Object.assign(settings, entry?.settings || {}); Object.assign(settings, entry?.settings || {});
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings }; const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
if (entry) { if (entry) {
tile.variant = String(entry.id); tile.variant = String(entry.id);
tile.title = String(entry.name || ''); tile.title = String(entry.name || '');
@@ -743,7 +1125,6 @@ function addTile(moduleId, entry = null) {
function removeTile(id) { function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id); const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return; if (idx < 0) return;
clearPeekTimer(id);
unmountModule(tiles[idx]); unmountModule(tiles[idx]);
tiles.splice(idx, 1); tiles.splice(idx, 1);
const entry = tileEls.get(id); const entry = tileEls.get(id);
@@ -997,7 +1378,7 @@ function buildModuleApi(tile, man, entry) {
/** /**
* The tile's title bar. Controls added here live next to the gear/close * The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The * buttons and are removed automatically when the instance stops. The
* whole bar can be hidden by the user via the notch, so nothing * whole bar folds away when the dashboard auto-hides it, so nothing
* essential should exist ONLY as a header control. * essential should exist ONLY as a header control.
*/ */
header: { header: {
@@ -1712,10 +2093,17 @@ function remountTile(tile) {
/* ── layout apply / reset ───────────────────────────────────────────── */ /* ── layout apply / reset ───────────────────────────────────────────── */
function renderLayout(list) { function renderLayout(layout) {
// Callers pass a whole layout; an array on its own is still understood so
// "reset" can simply hand over no tiles.
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles(); clearAllTiles();
// Density first: the tiles are clamped and placed against it.
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
tiles = list.map(sanitizeTile); tiles = list.map(sanitizeTile);
for (const tile of tiles) buildTile(tile); if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
scheduleOfferedRecheck();
refreshEmptyState(); refreshEmptyState();
rebuildDividers(); rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
@@ -1778,6 +2166,13 @@ function renderAddMenu(menu) {
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
// Density first: the one thing in here that is not a layout but the board
// the layouts are arranged on.
menu.appendChild(gridStepper());
const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider);
menu.appendChild(menuItem('Save as named layout…', async () => { menu.appendChild(menuItem('Save as named layout…', async () => {
menu.hidden = true; menu.hidden = true;
if (!tiles.length) { if (!tiles.length) {
@@ -1791,14 +2186,31 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
}); });
if (!name) return; if (!name) return;
try { try {
// Saving over a name is asked about first — and the copy it replaces
// is kept one back on the server, so a yes is not the end of it either.
const existing = await fetch('/api/layouts')
.then((r) => (r.ok ? r.json() : { layouts: [] }))
.then((b) => (b.layouts || []).find((l) => l.name === name))
.catch(() => null);
if (existing) {
const when = existing.updated ? new Date(existing.updated).toLocaleString() : 'earlier';
const go = await confirmModal({
title: `Replace “${name}”?`,
body: `It was saved ${when} with ${existing.tiles} tile${existing.tiles === 1 ? '' : 's'}. The copy it replaces is kept as its previous version.`,
okLabel: 'Replace',
});
if (!go) return;
}
const res = await fetch('/api/layouts/' + encodeURIComponent(name), { const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { tiles } }), body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
toast(`Saved “${name}” — any browser can load it now`); toast(body.replaced
? `Replaced “${name}” — the earlier copy is kept as its previous version`
: `Saved “${name}” — any browser can load it now`);
} catch (e) { } catch (e) {
toast(e.message, true); toast(e.message, true);
} }
@@ -1838,9 +2250,33 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it // Loading copies the layout into this browser's own state — it
// does not live-link browsers together. // does not live-link browsers together.
renderLayout(body.layout.tiles || []); let outgoing = null;
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
const gridBefore = COLS;
renderLayout(body.layout);
saveLayout(); saveLayout();
toast(`Loaded “${l.name}”`); try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
const unshown = unshownTileNames();
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`
: '';
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000,
action: outgoing ? {
label: 'Undo',
onClick() {
try {
renderLayout(JSON.parse(outgoing));
saveLayout();
toast('Back to the arrangement you had');
} catch {
toast('Could not go back', true);
}
},
} : null,
});
} catch (e) { } catch (e) {
toast(e.message, true); toast(e.message, true);
} }
@@ -1850,7 +2286,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.insertBefore(block, reset); menu.insertBefore(block, reset);
}); });
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */ /* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
const topbar = document.getElementById('topbar'); const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch'); const topbarNotch = document.getElementById('topbar-notch');
@@ -1877,41 +2313,6 @@ try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ } } catch { /* fine */ }
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
/** How long a peeked title bar stays after the pointer leaves its tile. */
const PEEK_HIDE_MS = 10000;
const peekTimers = new Map(); // tile id → timeout
function clearPeekTimer(id) {
const timer = peekTimers.get(id);
if (timer === undefined) return;
clearTimeout(timer);
peekTimers.delete(id);
}
function schedulePeekHide(id, el) {
clearPeekTimer(id);
peekTimers.set(id, setTimeout(() => {
peekTimers.delete(id);
el.classList.remove('head-peek');
}, PEEK_HIDE_MS));
}
function peekHead(tile, el, on) {
el.classList.toggle('head-peek', on);
clearPeekTimer(tile.id);
// Opened from a click the pointer is on the tile, so the countdown waits
// for it to leave — unless it already has (a keyboard or touch open).
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
}
/** Back to a plain screen: no peeks left open, no timers left running. */
function clearPeeks() {
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
}
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally /* Fullscreen is a viewing mode. The occupied columns stretch proportionally
@@ -2017,7 +2418,8 @@ function applyFullscreenFit() {
} }
function clearFullscreenFit() { function clearFullscreenFit() {
grid.style.gridTemplateColumns = ''; // Back to this browser's density, not the stylesheet's twelve.
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
grid.style.gridAutoRows = ''; grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll'); gridwrap.classList.remove('no-scroll');
for (const tile of tiles) { for (const tile of tiles) {
@@ -2026,6 +2428,13 @@ function clearFullscreenFit() {
} }
} }
autoHideBtn?.addEventListener('click', () => {
setAutoHideHeads(!autoHideHeads);
toast(autoHideHeads
? 'Title bars hide until you point at a tile\u2019s top edge'
: 'Title bars stay put');
});
fsBtn.addEventListener('click', () => { fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {}); document.documentElement.requestFullscreen?.().catch(() => {});
@@ -2039,14 +2448,15 @@ document.addEventListener('fullscreenchange', () => {
document.body.classList.toggle('fullscreen', active); document.body.classList.toggle('fullscreen', active);
if (active) { if (active) {
closeSettingsPopovers(); closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden'); menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring // let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else { } else {
setMenuHidden(menuHiddenBeforeFs, { persist: false }); setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit(); clearFullscreenFit();
applyAllHeadStates();
} }
}); });
@@ -2069,7 +2479,7 @@ function subscribeEvents() {
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`. // Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
sseSubscribe('/api/events', { sseSubscribe('/api/events', {
events: { events: {
'modules-changed': () => handleModulesChanged(), 'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
theme: handleThemeEvent, theme: handleThemeEvent,
}, },
}); });
@@ -2151,6 +2561,10 @@ async function boot() {
} }
renderLayout(loadLayoutFromStorage()); renderLayout(loadLayoutFromStorage());
subscribeEvents(); subscribeEvents();
// Entries a module builds at runtime (a timer per discovered source) can
// land a beat after the first registry read; look once more shortly after
// boot so a tile hidden only by that timing appears without a nudge.
setTimeout(async () => { if (await loadRegistry()) reconcileOfferedTiles(); }, 5000);
} }
boot(); boot();
+264 -120
View File
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
line-height: 1; line-height: 1;
} }
.tb-btn.icon svg { display: block; } .tb-btn.icon svg { display: block; }
/* A top-bar button that is a switch rather than an action lights up the same
way an open tile button does, so "on" reads the same everywhere. */
.tb-btn.is-on,
.tb-btn.is-on:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
/* ── dropdown menus ────────────────────────────────────────────────── */ /* ── dropdown menus ────────────────────────────────────────────────── */
@@ -179,6 +187,36 @@ body.menu-hidden #topbar {
white-space: normal; white-space: normal;
} }
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; } .menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
/* the Layouts menu's grid-density row: Grid Size − 12 + */
.menu-stepper {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 8px;
font-size: 13px;
}
.menu-stepper-label { flex: 1; }
.menu-stepper-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
}
.menu-stepper-btn:hover { border-color: var(--muted); }
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
.menu-stepper-value {
min-width: 28px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.menu-title { .menu-title {
padding: 6px 10px 3px; padding: 6px 10px 3px;
color: var(--muted); color: var(--muted);
@@ -270,11 +308,33 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease; transition: height 0.22s ease, border-bottom-width 0.22s ease;
} }
.tile.dragging .tile-head { cursor: grabbing; } .tile.dragging .tile-head { cursor: grabbing; }
/* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head { /* Folded away: the bar leaves the flow and parks just above the tile, so the
height: 0; module keeps the whole body and nothing reflows as the bar comes and goes.
border-bottom-width: 0; Pointing at the tile's top edge slides it back in over the content
overflow: hidden; (shell.js adds .head-peek) — and because it is the same element, its
buttons click and its width drags the tile just as they always did. */
.tile.head-off > .tile-head {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 7;
background: color-mix(in srgb, var(--panel) 90%, transparent);
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.tile.head-off.head-peek > .tile-head {
transform: none;
opacity: 1;
pointer-events: auto;
}
.tile.is-tinted.head-off > .tile-head {
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
} }
.tile-head .dot { .tile-head .dot {
@@ -290,7 +350,7 @@ body.menu-hidden #topbar {
.tile-head .dot.error { background: var(--danger); } .tile-head .dot.error { background: var(--danger); }
.tile-title { .tile-title {
flex: 1; flex: 0 1 auto;
min-width: 0; min-width: 0;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -321,6 +381,33 @@ body.menu-hidden #topbar {
.tile-btn.is-open, .tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); } .tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
/* The pin: a state, not an action, so it stays quiet until it is on and
sits closer to the label than to the buttons at the other end. It fades
in with the rest of the tile's chrome so a wall of unpinned tiles is not
a wall of pins. */
.tile-btn.pin {
width: 18px;
height: 18px;
margin-left: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-btn.pin svg { width: 12px; height: 12px; }
.tile:hover .tile-btn.pin,
.tile-btn.pin:focus-visible { opacity: 0.75; }
.tile-btn.pin:hover { opacity: 1; }
/* Pinned reads at a glance, whether or not anything is being pointed at. */
.tile-btn.pin.is-pinned,
.tile:hover .tile-btn.pin.is-pinned {
opacity: 1;
color: var(--accent);
}
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
.tile-btn.pin.is-pinned:hover { background: var(--border); }
/* Keeps the label hugging its pin however wide the tile is. */
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
/* the shell's tooltip: shown at once under the button the pointer is on */ /* the shell's tooltip: shown at once under the button the pointer is on */
#tip { #tip {
position: fixed; position: fixed;
@@ -374,55 +461,6 @@ body.menu-hidden #topbar {
} }
.tile-menu[hidden] { display: none; } .tile-menu[hidden] { display: none; }
/* notch on the title bar's bottom edge: click to hide/show the bar
(same idea as the ProdCom Listener's pull-down menubar handle) */
.tile-notch {
position: absolute;
top: 32px;
left: 50%;
transform: translateX(-50%);
width: 46px;
height: 13px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 6;
opacity: 0;
transition: opacity 0.15s ease, top 0.22s ease;
}
.tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; }
.tile.head-hidden .tile-notch { top: 0; }
.tile-notch::after {
content: "";
display: block;
width: 18px;
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
}
.tile-notch:hover::after { background: var(--text); }
/* Fullscreen is for watching, not arranging: every title bar folds away, and
a tile's own hidden/shown preference is left untouched for when the screen
comes back. The notch still peeks one open, and it folds back once the
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
body.fullscreen .tile:not(.head-peek) .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
body.fullscreen .tile.head-peek .tile-head {
height: 32px;
border-bottom-width: 1px;
}
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
.tile-body { .tile-body {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -431,80 +469,67 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
overflow: hidden; overflow: hidden;
} }
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
Every edge and both bottom corners resize, so there is nothing to point
at — the grips are invisible and the cursor says what each one does, the
way a window's own edges do. Together they tile the tile's outer 8px
without a gap: the top strip runs corner to corner, the sides run between
it and the bottom corners, and the bottom strip fills between those. */
.tile-resize,
.tile-resize-left,
.tile-resize-bottom,
.tile-resize-top,
.tile-resize-right,
.tile-resize-side {
position: absolute;
z-index: 5;
touch-action: none;
}
/* the two bottom corners: both directions at once */
.tile-resize, .tile-resize,
.tile-resize-left { .tile-resize-left {
position: absolute;
bottom: 0; bottom: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
touch-action: none;
z-index: 5;
color: var(--muted);
opacity: 0; /* corner indicators only show while hovering the tile */
transition: opacity 0.15s ease;
} }
.tile-resize { right: 0; cursor: nwse-resize; } .tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; } .tile-resize-left { left: 0; cursor: nesw-resize; }
.tile:hover .tile-resize,
.tile:hover .tile-resize-left { opacity: 0.6; }
.tile .tile-resize:hover,
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
.tile-resize::after,
.tile-resize-left::after {
content: "";
position: absolute;
bottom: 4px;
width: 9px;
height: 9px;
border-bottom: 2px solid currentColor;
}
.tile-resize::after {
right: 4px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px 0;
}
.tile-resize-left::after {
left: 4px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as /* top and bottom: height only */
the same 2px line as the corner handles it sits between, on the same .tile-resize-top,
4px inset, so the three read as one set of marks along the bottom. */
.tile-resize-bottom { .tile-resize-bottom {
position: absolute; height: 8px;
left: 24px;
right: 24px;
bottom: 0;
height: 10px;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: ns-resize; cursor: ns-resize;
touch-action: none;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
} }
.tile-resize-bottom::after { .tile-resize-top {
content: ""; top: 0;
width: 44px; left: 0;
max-width: 60%; right: 0;
height: 0; /* Above a revealed title bar: the bar slides in under the pointer here,
margin-bottom: 4px; and this strip has to stay the top edge rather than become part of it. */
border-bottom: 2px solid currentColor; z-index: 8;
} }
.tile:hover .tile-resize-bottom { opacity: 0.6; } .tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); } /* left and right: width only, between the top strip and the bottom corners */
.tile-resize-right,
.tile-resize-side {
top: 8px;
bottom: 20px;
width: 8px;
cursor: ew-resize;
}
.tile-resize-right { right: 0; }
.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam; /* When two tiles share a vertical edge, an invisible strip sits on the seam.
hovering reveals a small macOS-split-view-style grab pill. Dragging it Dragging it trades width between the two tiles rather than resizing one,
trades width between the two tiles. */ so it sits above their own edge grips. Like them it draws nothing at rest
— only while it is being dragged, to show which seam is moving. */
.tile-divider { .tile-divider {
position: absolute; position: absolute;
width: 14px; width: 14px;
@@ -528,22 +553,24 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
transition: opacity 0.15s ease, background 0.15s ease; transition: opacity 0.15s ease, background 0.15s ease;
} }
.tile-divider:hover::after,
.tile-divider.linked::after { opacity: 0.7; }
.tile-divider.active::after { opacity: 1; background: var(--text); } .tile-divider.active::after { opacity: 1; background: var(--text); }
/* Hide the pills while a tile itself is being moved or corner-resized — /* Drop the seams while a tile itself is being moved or resized — they're
they're rebuilt for the new geometry on release. */ rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; } #grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */ /* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the /* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (corner handles, seam pills) are parked. */ editing affordances (the rim grips and the seams) are parked. */
body.fullscreen .tile-resize, body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left, body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom, body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-resize-top,
body.fullscreen .tile-resize-right,
body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider, body.fullscreen .tile-divider,
body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; } body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; } body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; } #gridwrap.no-scroll { overflow-y: hidden; }
@@ -909,6 +936,24 @@ body.fullscreen #grid { padding-bottom: 0; }
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
} }
#toast[hidden] { display: none; } #toast[hidden] { display: none; }
#toast button {
margin-left: 12px;
padding: 3px 10px;
border: 1px solid var(--accent);
border-radius: 5px;
background: none;
color: var(--accent);
font: inherit;
font-weight: 700;
cursor: pointer;
}
#toast button:hover { background: var(--accent); color: var(--accent-text); }
#modal .modal-copy {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
margin: 6px 0 14px;
}
#toast.error { border-color: var(--danger); } #toast.error { border-color: var(--danger); }
/* ── scrollbars ────────────────────────────────────────────────────── */ /* ── scrollbars ────────────────────────────────────────────────────── */
@@ -944,8 +989,10 @@ body.fullscreen #grid { padding-bottom: 0; }
every shell element follows, and a colour a tile is configured with (set every shell element follows, and a colour a tile is configured with (set
on the tile itself) still wins. The :root block at the top of this file is on the tile itself) still wins. The :root block at the top of this file is
"booth", the default; the list of ids and names lives in server.js "booth", the default; the list of ids and names lives in server.js
(GET /api/theme). All four are booth-grade: high contrast, the accent (GET /api/theme). Every one is booth-grade: high contrast, the accent
nowhere near the danger red or the warning amber, muted still readable. */ nowhere near the danger red or the warning amber, muted still readable.
Each sets --accent-text too when the default (a near-black green, for
booth's mint) would look wrong against its own accent. */
/* Harbor — deep navy, cyan accent */ /* Harbor — deep navy, cyan accent */
html[data-theme="harbor"] { html[data-theme="harbor"] {
@@ -996,3 +1043,100 @@ html[data-theme="daylight"] {
--danger: #d1242f; --danger: #d1242f;
--warn: #b26a00; --warn: #b26a00;
} }
/* Midnight — indigo night, violet accent */
html[data-theme="midnight"] {
--bg: #0a0b18;
--panel: #14162b;
--border: #272a4a;
--text: #e8e9fb;
--muted: #9a9cc8;
--accent: #9d8cff;
--accent-text: #12103a;
--danger: #ff5a72;
--warn: #f5b93a;
}
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
and the amber never read as one another) */
html[data-theme="moss"] {
--bg: #08120c;
--panel: #101e15;
--border: #1f3728;
--text: #e9f4ea;
--muted: #8fb09a;
--accent: #8fdb3f;
--accent-text: #15250a;
--danger: #ff5a52;
--warn: #ff9f1c;
}
/* Plum — aubergine, purple accent */
html[data-theme="plum"] {
--bg: #110a17;
--panel: #1d1228;
--border: #332042;
--text: #f2e9f8;
--muted: #b195c2;
--accent: #c084fc;
--accent-text: #260a37;
--danger: #ff4d4f;
--warn: #f7b53b;
}
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
Ember, which is warm throughout */
html[data-theme="sepia"] {
--bg: #12100b;
--panel: #1d1913;
--border: #33291d;
--text: #f4ede0;
--muted: #b3a48b;
--accent: #5eead4;
--accent-text: #06231e;
--danger: #ff5a52;
--warn: #ffc247;
}
/* Blackout — true black for a dark room, where any glow off the screen
carries; everything else is as bright as it can be against it */
html[data-theme="blackout"] {
--bg: #000000;
--panel: #0a0a0a;
--border: #1f1f1f;
--text: #ffffff;
--muted: #9e9e9e;
--accent: #00e5ff;
--accent-text: #00212a;
--danger: #ff3b30;
--warn: #ffcc00;
}
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
darken, as they must on a light background, and the indigo accent takes
white text. */
html[data-theme="paper"] {
--bg: #f5f1e8;
--panel: #fffdf8;
--border: #ddd5c4;
--text: #1c1a15;
--muted: #6b6455;
--accent: #4f46e5;
--accent-text: #ffffff;
--danger: #c8102e;
--warn: #9a6700;
}
/* Iron — no colour of its own: the accent is near-white, so the only hues on
screen are a module's own and the red/amber that mean something */
html[data-theme="iron"] {
--bg: #0c0c0d;
--panel: #161617;
--border: #2b2b2d;
--text: #f2f2f3;
--muted: #9a9a9d;
--accent: #d8d8dc;
--accent-text: #131314;
--danger: #ff4d4f;
--warn: #ffb224;
}
+348 -30
View File
@@ -12,7 +12,8 @@
* Configure via config/proddash.json (defaults, in the repo), an optional * Configure via config/proddash.json (defaults, in the repo), an optional
* proddash.json in the data directory (this machine's overrides), or env vars: * proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500) * PORT (default: value in proddash.json, else 24500)
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) * PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
* also the way back in if the PIN is ever forgotten)
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base * PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
* URL such as http://10.3.8.90:3000) * URL such as http://10.3.8.90:3000)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below) * PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
@@ -711,6 +712,13 @@ const THEMES = [
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } }, { id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } }, { id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } }, { id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
]; ];
const DEFAULT_THEME = THEMES[0].id; const DEFAULT_THEME = THEMES[0].id;
@@ -766,7 +774,7 @@ function parseRepoSetting(value, fallbackBranch) {
} }
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch) const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main'); || parseRepoSetting('michael/ProdDash', 'main');
/** /**
* Which server the code comes from. GitHub unless `forge` names another — * Which server the code comes from. GitHub unless `forge` names another —
@@ -1096,12 +1104,13 @@ function uninstallModule(id) {
/* — updating the shell itself — */ /* — updating the shell itself — */
function copyTree(src, dst) { function copyTree(src, dst, include = null) {
fs.mkdirSync(dst, { recursive: true }); fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) { for (const e of fs.readdirSync(src, { withFileTypes: true })) {
if (include && !include(e.name)) continue;
const s = path.join(src, e.name); const s = path.join(src, e.name);
const d = path.join(dst, e.name); const d = path.join(dst, e.name);
if (e.isDirectory()) copyTree(s, d); if (e.isDirectory()) copyTree(s, d, include);
else fs.copyFileSync(s, d); else fs.copyFileSync(s, d);
} }
} }
@@ -1121,25 +1130,83 @@ async function updateShell() {
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000); const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | ')); console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
} else { } else {
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
const staging = path.join(ROOT, '.proddash-update'); const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) { for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || keep(rel)) continue; if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel); const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data); fs.writeFileSync(target, data);
} }
if (!fs.existsSync(path.join(staging, 'server.js'))) { const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) {
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).'); throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
} }
// Nothing is swapped in until the new server at least parses. A file that
// arrived truncated or mangled would otherwise be found only by a process
// that cannot start — the one state this app cannot talk its way out of.
try {
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
// node --check leads with the staging path and a caret diagram; the
// line that names the fault is the one worth repeating.
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
}
keepPreviousCopy();
copyTree(staging, ROOT); copyTree(staging, ROOT);
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
} }
return { from: SHELL_VERSION, to: remote.version || '', method: status.method }; return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
} }
/* ── what an update must not touch ───────────────────────────────────
Anything inside the data directory. Normally it sits well outside the app
folder and none of this applies, but when it cannot (an unwritable
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
directory IS the app's config/ — and an update would then write the repo's
shipped defaults over this machine's own proddash.json, taking the admin
PIN, the theme and the forge with it. The two legacy names stay listed for
an install whose state never moved out. */
const DATA_DIR_INSIDE_ROOT = (() => {
const rel = path.relative(ROOT, DATA_DIR);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
return rel.split(path.sep).join('/') + '/';
})();
function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
}
/* ── the way back ────────────────────────────────────────────────────
The copy being replaced is set aside first, so there is always one known
version to return to. The launcher restores it if the new one will not
stay up (see ServerController); by hand it is a rename. It is replaced by
the next update, never accumulated. */
function previousCopyPath() {
return `${ROOT}.previous`;
}
function keepPreviousCopy() {
const previous = previousCopyPath();
try {
fs.rmSync(previous, { recursive: true, force: true });
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
} catch (err) {
// Worth saying, not worth refusing the update over.
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
}
}
/** /**
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us * Exit so the new code runs. Exit code 75 tells a launcher loop to start us
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our * again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
@@ -1170,12 +1237,109 @@ function restartServer(reason) {
/* ── admin API ──────────────────────────────────────────────────────── */ /* ── admin API ──────────────────────────────────────────────────────── */
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
machine's proddash.json as a scrypt hash — never in the clear, because that
file gets copied between machines. A dashboard needs no PIN; only /admin
and the admin API do. Two ways back in if it is ever forgotten: set
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
proddash.json in the data directory and restart. `adminPasscode` (the older
free-text setting) still works so an existing install keeps running. */
const PIN_RE = /^\d{6}$/;
const PIN_MAX_WRONG = 5;
const PIN_LOCKOUT_MS = 30000;
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || ''); const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */ /** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
const adminToken = crypto.randomBytes(24).toString('hex'); const adminToken = crypto.randomBytes(24).toString('hex');
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
let adminPinHash = String(shellConfig.adminPin || '');
const pinAttempts = new Map(); // ip → { count, lockedUntil }
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
}
/** Constant-time check of a PIN against the stored hash. */
function pinMatches(given, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const expected = Buffer.from(parts[2], 'hex');
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
/** Is a credential required at all? */
function adminLocked() {
return Boolean(ADMIN_PASSCODE || adminPinHash);
}
/** True when `given` is the credential in force (the env passcode, or the PIN). */
function credentialOk(given) {
const s = String(given ?? '');
if (!s) return false;
if (ADMIN_PASSCODE) {
const a = Buffer.from(s);
const b = Buffer.from(ADMIN_PASSCODE);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
return adminPinHash ? pinMatches(s, adminPinHash) : false;
}
function clientIp(req) {
return String(req.socket?.remoteAddress || 'unknown');
}
/** Seconds of lockout left for this address, or 0. */
function lockoutLeft(ip) {
const a = pinAttempts.get(ip);
if (!a || !a.lockedUntil) return 0;
const left = a.lockedUntil - Date.now();
if (left <= 0) {
pinAttempts.delete(ip);
return 0;
}
return Math.ceil(left / 1000);
}
function noteWrongPin(ip) {
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
a.count += 1;
if (a.count >= PIN_MAX_WRONG) {
a.count = 0;
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
}
pinAttempts.set(ip, a);
}
/**
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
* key in this machine's proddash.json. Throws an httpError the route reports.
*/
function setAdminPin(pin) {
const next = String(pin ?? '');
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
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 PIN.`);
}
}
const hash = next ? hashPin(next) : '';
const out = { ...existing };
if (hash) out.adminPin = hash;
else delete out.adminPin;
writeJson(file, out);
adminPinHash = hash;
shellConfig.adminPin = hash;
pinAttempts.clear();
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
return { pinSet: Boolean(hash) };
}
function isAuthed(req) { function isAuthed(req) {
if (!ADMIN_PASSCODE) return true; if (!adminLocked()) return true;
const cookies = String(req.headers.cookie || ''); const cookies = String(req.headers.cookie || '');
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken); return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
} }
@@ -1183,7 +1347,7 @@ function isAuthed(req) {
/** Guard a state-changing admin route. Returns true when the request was refused. */ /** Guard a state-changing admin route. Returns true when the request was refused. */
function refuseAdminWrite(req, res) { function refuseAdminWrite(req, res) {
if (!isAuthed(req)) { if (!isAuthed(req)) {
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
return true; return true;
} }
// Requiring application/json forces a CORS preflight that a hostile page // Requiring application/json forces a CORS preflight that a hostile page
@@ -1268,10 +1432,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch { } catch {
return sendJson(res, 400, { error: 'Malformed request.' }); return sendJson(res, 400, { error: 'Malformed request.' });
} }
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false }); if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
if (String(body.passcode || '') !== ADMIN_PASSCODE) { const ip = clientIp(req);
return sendJson(res, 403, { error: 'Wrong passcode.' }); const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
// `passcode` is what the older admin page sent; `pin` is what this one does.
if (!credentialOk(body.pin ?? body.passcode)) {
noteWrongPin(ip);
const now = lockoutLeft(ip);
return sendJson(res, 403, now
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
: { error: 'Wrong PIN.' });
} }
pinAttempts.delete(ip);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`, 'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
@@ -1280,12 +1453,50 @@ async function handleAdminApi(req, res, urlPath) {
return res.end(JSON.stringify({ ok: true })); return res.end(JSON.stringify({ ok: true }));
} }
if (urlPath === '/api/admin/pin' && 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.' });
}
// Changing a credential re-asserts it, so a browser left open on the
// admin page can't be used to take the machine over.
if (adminPinHash) {
const ip = clientIp(req);
const left = lockoutLeft(ip);
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
noteWrongPin(ip);
return sendJson(res, 403, { error: 'That is not the current PIN.' });
}
pinAttempts.delete(ip);
}
try {
const result = setAdminPin(body.pin);
// Whoever just set it has proved they are allowed, so hand them the
// session rather than bouncing them to the unlock card they just built.
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
'Cache-Control': 'no-store',
});
return res.end(JSON.stringify(result));
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
}
}
if (urlPath === '/api/admin/state' && req.method === 'GET') { if (urlPath === '/api/admin/state' && req.method === 'GET') {
if (!isAuthed(req)) { if (!isAuthed(req)) {
return sendJson(res, 401, { authRequired: true, authed: false }); return sendJson(res, 401, { authRequired: true, authed: false });
} }
return sendJson(res, 200, { return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE), authRequired: adminLocked(),
// The env passcode can't be changed from here; the page says so.
pinSet: Boolean(adminPinHash),
pinFromEnv: Boolean(ADMIN_PASSCODE),
authed: true, authed: true,
version: SHELL_VERSION, version: SHELL_VERSION,
dataDir: DATA_DIR, dataDir: DATA_DIR,
@@ -1390,19 +1601,76 @@ function layoutPath(name) {
return path.join(LAYOUTS_DIR, name + '.json'); return path.join(LAYOUTS_DIR, name + '.json');
} }
/** Keep only the fields a layout is made of; everything else is dropped. */ /** The copy a save replaced — one back, kept until the next save replaces it.
Names cannot contain a dot (layoutNameOk), so this can never be a real
layout's own file. */
function previousLayoutPath(name) {
return path.join(LAYOUTS_DIR, name + '.previous.json');
}
/* A saved layout is `{ v, autoHideHeads, tiles }`. The fields the shell knows
are checked for shape; anything else a tile carries rides along untouched.
That second part is the lesson of a bug: this function used to rebuild each
tile from the fields it knew and drop the rest, and for seventeen days it
dropped `variant` — every solo timer saved to a server came back as "All
timers". The server is the part of ProdDash that lags (a booth updates
weeks after the dev machine), so it must never be the one deciding which
tile fields exist. Retired fields are the one exception: they are dropped
so an old file does not haunt a new client. */
const LAYOUT_VERSION = 2;
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
const MAX_LAYOUT_TILES = 100;
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
/** An unknown tile field is kept when it is a sane key holding small, plain JSON. */
function keepUnknownTileField(key, value) {
if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key)) return false;
switch (typeof value) {
case 'string': return value.length <= 2000;
case 'number': return Number.isFinite(value);
case 'boolean': return true;
case 'object':
if (value === null) return false;
try { return JSON.stringify(value).length <= 20000; } catch { return false; }
default: return false;
}
}
function sanitizeLayout(raw) { function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : []; const tiles = Array.isArray(raw?.tiles) ? raw.tiles : [];
return { return {
tiles: tiles.map((t) => ({ v: LAYOUT_VERSION,
id: String(t?.id || ''), // Whether tile title bars auto-hide is part of how a dashboard is
module: String(t?.module || ''), // arranged, so it travels with the layout like the tiles themselves.
x: Number(t?.x) || 0, autoHideHeads: Boolean(raw?.autoHideHeads),
y: Number(t?.y) || 0, // The grid density the layout was arranged on, when it says and it is one
w: Number(t?.w) || 1, // the shell knows; a browser loading it cuts its board the same way.
h: Number(t?.h) || 1, ...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {}, tiles: tiles.map((t) => {
})), const tile = {
id: String(t?.id || ''),
module: String(t?.module || ''),
x: Number(t?.x) || 0,
y: Number(t?.y) || 0,
w: Number(t?.w) || 1,
h: Number(t?.h) || 1,
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
// Which picker entry the tile came from, and what it was called.
variant: String(t?.variant || ''),
title: String(t?.title || ''),
headPinned: Boolean(t?.headPinned),
};
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
for (const [key, value] of Object.entries(t && typeof t === 'object' ? t : {})) {
if (KNOWN_TILE_FIELDS.has(key) || RETIRED_TILE_FIELDS.has(key)) continue;
if (keepUnknownTileField(key, value)) tile[key] = value;
}
return tile;
}),
}; };
} }
@@ -1414,17 +1682,23 @@ async function handleLayoutsApi(req, res, urlPath) {
} catch { /* no layouts dir yet */ } } catch { /* no layouts dir yet */ }
const layouts = []; const layouts = [];
for (const file of files.sort()) { for (const file of files.sort()) {
if (file.endsWith('.previous.json')) continue; // a save's one-back copy, listed on its layout
const name = file.slice(0, -5); const name = file.slice(0, -5);
const data = readJson(path.join(LAYOUTS_DIR, file), null); const data = readJson(path.join(LAYOUTS_DIR, file), null);
if (!data) continue; if (!data) continue;
let updated = null; let updated = null;
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ } try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated }); layouts.push({
name,
tiles: Array.isArray(data.tiles) ? data.tiles.length : 0,
updated,
hasPrevious: fs.existsSync(previousLayoutPath(name)),
});
} }
return sendJson(res, 200, { layouts }); return sendJson(res, 200, { layouts });
} }
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/); const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename|\/restore)?$/);
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' }); if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
let name; let name;
try { try {
@@ -1457,25 +1731,34 @@ async function handleLayoutsApi(req, res, urlPath) {
} }
const layout = sanitizeLayout(body.layout); const layout = sanitizeLayout(body.layout);
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' }); if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
if (layout.tiles.length > MAX_LAYOUT_TILES) {
// Saying no beats quietly saving the first hundred and losing the rest.
return sendJson(res, 400, { error: `A layout can hold ${MAX_LAYOUT_TILES} tiles; this one has ${layout.tiles.length}.` });
}
const replaced = fs.existsSync(layoutPath(name));
try { try {
// The copy being replaced is kept one back, so a save over the wrong
// name is a restore away rather than gone.
if (replaced) fs.copyFileSync(layoutPath(name), previousLayoutPath(name));
writeJson(layoutPath(name), layout); writeJson(layoutPath(name), layout);
} catch (err) { } catch (err) {
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` }); return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
} }
return sendJson(res, 200, { ok: true, name }); return sendJson(res, 200, { ok: true, name, replaced });
} }
if (!m[2] && req.method === 'DELETE') { if (!m[2] && req.method === 'DELETE') {
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
try { try {
fs.unlinkSync(layoutPath(name)); fs.unlinkSync(layoutPath(name));
} catch { } catch {
return sendJson(res, 404, { error: `No layout named "${name}".` }); return sendJson(res, 404, { error: `No layout named "${name}".` });
} }
fs.rmSync(previousLayoutPath(name), { force: true });
return sendJson(res, 200, { ok: true }); return sendJson(res, 200, { ok: true });
} }
if (m[2] && req.method === 'POST') { if (m[2] === '/rename' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return; if (refuseAdminWrite(req, res)) return;
let body = {}; let body = {};
try { try {
@@ -1499,6 +1782,25 @@ async function handleLayoutsApi(req, res, urlPath) {
return sendJson(res, 200, { ok: true, name: next }); return sendJson(res, 200, { ok: true, name: next });
} }
// Put back the copy the last save replaced. The two swap, so a restore is
// itself one restore away from being undone.
if (m[2] === '/restore' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
const current = layoutPath(name);
const previous = previousLayoutPath(name);
if (!fs.existsSync(previous)) return sendJson(res, 404, { error: `"${name}" has no previous version to go back to.` });
try {
const swapTmp = current + '.swap';
const hadCurrent = fs.existsSync(current);
if (hadCurrent) fs.renameSync(current, swapTmp);
fs.renameSync(previous, current);
if (hadCurrent) fs.renameSync(swapTmp, previous);
} catch (err) {
return sendJson(res, 500, { error: 'Restore failed: ' + (err?.message || err) });
}
return sendJson(res, 200, { ok: true, name });
}
sendJson(res, 405, { error: 'Method not allowed.' }); sendJson(res, 405, { error: 'Method not allowed.' });
} }
@@ -1751,6 +2053,22 @@ server.on('error', (err) => {
server.listen(PORT, '0.0.0.0'); server.listen(PORT, '0.0.0.0');
/* An update proves itself by staying up. Once this version has, the copy it
replaced is dropped — so `app.previous` existing always means "the last
update has not proven itself yet", which is exactly what the launcher
should roll back to and nothing more. */
const UPDATE_PROVEN_AFTER_MS = 90000;
setTimeout(() => {
const previous = previousCopyPath();
if (!fs.existsSync(previous)) return;
try {
fs.rmSync(previous, { recursive: true, force: true });
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
} catch (err) {
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
}
}, UPDATE_PROVEN_AFTER_MS).unref();
// Update check: shortly after boot, then every few hours (the admin page // Update check: shortly after boot, then every few hours (the admin page
// shows the result; nothing is installed without someone clicking). // shows the result; nothing is installed without someone clicking).
if (!process.env.PRODDASH_NO_REMOTE_CHECK) { if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
+159
View File
@@ -0,0 +1,159 @@
'use strict';
/**
* Round-trips a layout through the server's layouts API and checks that
* nothing is lost on the way: a fully-populated tile, a tile field the server
* has never heard of, the one-back copy a save leaves behind, restore, the
* tile cap and delete.
*
* node tools/layout-roundtrip.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.
*
* It exists because of one bug: for seventeen days the server rebuilt each
* saved tile from the fields it knew and dropped the rest — including
* `variant`, so every solo timer saved to a booth came back as "All timers".
* A field added on the client must survive a server that has not heard of
* it, and this is where that is checked.
*/
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
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);
}
(async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-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 layoutsDir = path.join(dataDir, 'layouts');
let failed = false;
try {
await waitFor(base + '/api/theme');
// Every field the shell writes today, one it will write tomorrow, and one
// it has stopped writing.
const tile = {
id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2,
settings: { showNames: false, __tint: '#112233' },
variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 },
futureField: 'kept', futureFlag: true, futureBox: { a: 1 },
headHidden: true,
};
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
r = await api('/api/layouts/Booth%20A');
assert.equal(r.status, 200);
const got = r.body.layout;
assert.equal(got.v, 2, 'the file carries a schema version');
assert.equal(got.autoHideHeads, true);
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
const t = got.tiles[0];
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
assert.deepEqual(t.settings, tile.settings, 'settings');
assert.deepEqual(t.minSize, tile.minSize, 'minSize');
assert.equal(t.futureField, 'kept', 'an unknown string field survives');
assert.equal(t.futureFlag, true, 'an unknown boolean survives');
assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives');
assert.equal('headHidden' in t, false, 'a retired field is dropped');
// Saving over the name keeps the copy it replaced.
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
assert.equal(r.status, 200);
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
r = await api('/api/layouts');
assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]],
'the listing shows one layout with a previous copy — not two layouts');
// Restore swaps them, so a second restore undoes the first. Admin is open
// here (no PIN), which is what a fresh data directory gives.
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body));
r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back');
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 200);
r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
// Too many tiles is refused, not trimmed.
const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i }));
r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) });
assert.equal(r.status, 400);
assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap');
assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written');
// Delete takes the previous copy with it.
r = await api('/api/layouts/Booth%20A', { method: 'DELETE' });
assert.equal(r.status, 200);
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false);
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too');
r = await api('/api/layouts');
assert.deepEqual(r.body.layouts, [], 'nothing left listed');
console.log('layout round-trip: 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');
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(dataDir, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();
+49
View File
@@ -0,0 +1,49 @@
// Minimal Planning Center mock for the Band Lineup module: service types, one
// plan today, its team_members (with person+team includes), People birthdates,
// and avatar bytes. Enough to exercise the whole board build.
const http = require('http');
const PORT = Number(process.argv[2] || 24710);
const JPEG = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==', 'base64');
const TODAY = new Date();
const iso = (d) => d.toISOString();
// One person's birthday lands in this Wed–Tue week (today is a Wed in the test).
const inWeek = new Date(TODAY.getFullYear(), TODAY.getMonth(), TODAY.getDate() + 2);
const bdayInWeek = `1994-${String(inWeek.getMonth()+1).padStart(2,'0')}-${String(inWeek.getDate()).padStart(2,'0')}`;
const PEOPLE = {
p1: { name: 'Lemuel Maldonado', pos: 'Vox 1', team: 'Vocals', bd: '1990-03-04' },
p2: { name: 'Wes Carter', pos: 'Vox 2', team: 'Vocals', bd: null },
p3: { name: 'Jessica Proulx', pos: 'Vox 3', team: 'Vocals', bd: '1992-11-01' },
p4: { name: 'Jeremy Cruz', pos: 'Guitar / Lead', team: 'Band', bd: null },
p5: { name: 'Alex Gaitan', pos: 'Drums', team: 'Band', bd: bdayInWeek }, // celebrating
p6: { name: 'Rosaliz Gomez', pos: 'Piano', team: 'Band', bd: null },
p7: { name: 'George Acosta', pos: 'MC', team: 'Production', bd: null },
p8: { name: 'Not Shown', pos: 'Camera 2', team: 'Video', bd: null }, // team not allowed → filtered out
};
const TEAMS = { Vocals: 't1', Band: 't2', Production: 't3', Video: 't4' };
function send(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); const p = u.pathname;
if (p.startsWith('/avatar/')) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(JPEG); }
if (p === '/services/v2/folders') return send(res, { data: [] });
if (p === '/services/v2/service_types') return send(res, { data: [{ id: '1', type: 'ServiceType', attributes: { name: 'Sunday Service' }, relationships: { parent: { data: null } } }] });
if (/^\/services\/v2\/service_types\/1\/plans$/.test(p)) {
if (u.searchParams.get('filter') === 'past') return send(res, { data: [] });
return send(res, { data: [{ id: '100', type: 'Plan', attributes: { sort_date: iso(TODAY), title: 'Taking Thoughts Captive - Part 4', dates: 'September 20, 2026' } }] });
}
if (/^\/services\/v2\/service_types\/1\/plans\/100\/team_members$/.test(p)) {
const data = []; const included = [];
for (const [id, v] of Object.entries(PEOPLE)) {
data.push({ id: `tm-${id}`, type: 'PlanPerson', attributes: { status: 'C', team_position_name: v.pos }, relationships: { person: { data: { type: 'Person', id } }, team: { data: { type: 'Team', id: TEAMS[v.team] } } } });
included.push({ id, type: 'Person', attributes: { full_name: v.name, photo_url: `http://127.0.0.1:${PORT}/avatar/${id}` } });
}
for (const [name, id] of Object.entries(TEAMS)) included.push({ id, type: 'Team', attributes: { name } });
return send(res, { data, included, links: {} });
}
const person = p.match(/^\/people\/v2\/people\/([^/]+)$/);
if (person) { const v = PEOPLE[person[1]]; return send(res, { data: { id: person[1], type: 'Person', attributes: { birthdate: v ? v.bd : null } } }); }
res.writeHead(404); res.end('{}');
});
server.listen(PORT, '127.0.0.1', () => console.log(`[pco-band-mock] on http://127.0.0.1:${PORT}`));