Initial release: speaker timer, confidence monitor, docs and release pipeline - #1
Merged
Merged
Conversation
Set up the axum server that the later milestones build on. It carries a clap CLI, an embedded frontend, a room registry, and a health endpoint. Room names come from the URL path. RoomName validates a name before it reaches the filesystem. The rule allows 1 to 64 characters of a-z, 0-9, dash and underscore, lowercased. It reserves api, assets and healthz. That keeps a name safe in a URL and safe as a snapshot filename in M5. The frontend ships inside the binary through rust-embed. The build stays at one cargo command with no Node toolchain. The plan for a venue laptop depends on that. Web assets are placeholders at this stage. M3 and M4 replace them. Spec: SPEC.md milestone M1.
Add the show clock: timer modes, transport commands, warning phases, and the room state that clients read. The readout is a pure function of the timer and a timestamp. No task ticks a room, so the server runs no timing loop and every case tests at a fixed instant. A running timer stores the epoch millisecond its current segment started, and elapsed time accrues across pauses. Two rules are easy to get wrong, so both carry tests. A zero warning threshold is off, not a threshold that fires at once. A clock that jumps backwards saturates to zero elapsed rather than underflowing. Countdown past zero either counts negative or holds at zero, set per room by on_expire. Overtime still reports the expired phase in both cases, which is what turns the viewer red. Commands carry a serde tag. The same JSON envelope then serves the socket in M3 and the HTTP API in M5. Room bumps rev only when a command changed state. That keeps a repeated start from waking every client. Spec: SPEC.md milestone M2.
Add the sync layer and the first working pair of screens. An operator opens the console, presses start, and the viewer counts down in step. The server owns the clock. Every state frame carries server_time_ms, and each client measures a clock offset from three pings. The browser then renders from its own animation frame loop and corrects on each frame. Between frames the digits stay smooth, which polling cannot do. A room broadcasts its full state, not a diff. The frame runs near 400 bytes, so a diff protocol would buy nothing and would cost reconnect correctness. Clients also get a frame every 15 seconds, which doubles as a clock resynchronization. Each client subscribes before it joins, so it receives its own join frame. That removes a separate initial send and keeps one code path. Client counts sit in the frame, so the console can show that a viewer is connected. Only a socket opened with role=edit may send a command. A viewer that tries gets an error frame and the room does not change. Auth arrives in M5, and this is the structural half of it. A bad frame returns an error and leaves the socket open. A viewer that loses the network keeps counting from its last anchor. It warns after five seconds and reconnects with capped backoff. shared.js repeats the readout math so the browser can render between frames. Rust and JavaScript must agree on it, so shared.test.mjs asserts the same cases as tests/timer.rs. Both suites run through make test. The node test runner ships with node, so this adds no dependency and no build step. Spec: SPEC.md milestone M3.
Add the show features an operator needs during a talk, and the console that drives them. The set covers notes to the speaker, blackout, flash, thresholds, title, next up, wall clock, progress bar, mirror and text size. Every field of the message and display commands is optional. The console can change the tone without resending the text, or hide a note without clearing it. A command that changes nothing leaves rev alone, so a repeated blackout does not wake the viewers. Flash is an event, not a state. The command stamps display.flash_at with the server clock and always counts as a change. A viewer flashes when the value differs from the one it last saw, so a second flash fires again. The spec called this a boolean, which cannot express a repeat. Scale clamps to 50 and 200 percent in the room, not in the browser. Any client sending the command gets the same bounds. A state frame arrives on every command, and the naive console overwrites whatever the operator has half typed. Text inputs now track a draft and refuse a server value until the two agree. The debounced title send also captures the value at keystroke time rather than at timer fire time. Single-letter keys drive the hot path: space, r, b and f. The handler ignores them while focus sits in a text field. Verified in a browser against a live server. Both screen toggles and the timer round trip from console to viewer. Spec: SPEC.md milestone M4.
Add what the binary needs outside a show. The set covers an operator token, room snapshots, an HTTP command API, the room picker, and the README. One token guards every write. The console and the mutating endpoints accept it as a bearer header, a query parameter, or a cookie. Only the query path sets the cookie, because only a browser navigation needs one. An operator pastes the link once and then navigates freely. Comparison runs in constant time. Viewer routes never ask for the token, since a display machine in a booth cannot type one. The HTTP API takes the same command envelope as the socket. Companion and a shell script therefore reach every operator action. GET returns the frame the socket sends, so a polling client can reuse the browser code. Snapshots are opt in through --state-dir. A room writes one second after it settles. The write goes through a temporary file and a rename, so a crash cannot leave half a file. A snapshot records the save time. A timer that was running reloads paused at its saved elapsed time, because a restart means the show already stopped. Loading skips unreadable files and bad names rather than failing the boot. The picker builds both room links and a QR code for the viewer URL, and lists the live rooms. Room name normalization and link building moved into shared.js so node can test them. The browser harness could not drive text fields, so the pure functions carry the tests. The HTTP API verified the rendering paths instead. Verified against a live server. State reaches disk, a restart restores the room with the timer paused, and both browsers reconnect on their own. Spec: SPEC.md milestone M5.
Add the running order. A room holds an ordered list of cues, and loading one points the timer and the screen at it. Next and previous walk the list. Auto advance starts the following cue when a cue runs out. A cue id is never reused. Remove a cue and add another, and the new one gets a fresh id. A console holding a stale list therefore cannot load the wrong cue. Loading a cue resets the run and sets the duration. It also writes the cue title and the following title onto the screen. Auto advance needs to notice zero, which no other part of the server does. The readout stays a pure function of the timer and a timestamp. A 200 millisecond scan handles this one case instead. It only touches rooms with auto advance on and a running countdown. Nothing about the readout depends on it. The last cue does not advance. It runs into overtime and stays there, which is what an operator wants at the end of a session. The console gains a rundown panel. It marks the active cue in the list. Each row carries load, reorder and remove. Below sit an add form and the transport. The header compares the time left in the plan against its total. Keys n and p step the list. Verified against a live server. A four second cue advanced on its own, carried the title across, and started the next cue running. Spec: SPEC.md milestone M6.
Bring the spec in line with the code. It was a proposal, and six milestones later three parts of it no longer matched. Flash is an event, not a boolean. Auto advance runs a narrow scan, which the original design ruled out for the readout and still does. The frontend carries a node test suite that the design did not plan for. Also add the endpoints M5 and M6 introduced, and the Rundown state. A stale design doc is worse than none, and this one still has to explain why the alternatives lost.
Record what comes after the six milestones, ordered by what an operator reaches for most. The top unchecked item is next, and each item is one commit. The list separates features from polish, and keeps the reason for each item next to it. Three items came out of driving the app. Those are the toggle styling collision, the wrapping nudge row, and the missing reduced motion support.
Put the common notes to the speaker one press away. A room carries a short list of presets, and the console shows one button per preset above the message box. Typing is the thing an operator has no hands for during a talk. Every product in the survey carries presets for that reason. A preset holds text and a tone, so pressing one sets both and shows the note. The room owns the list rather than the browser. Every console sees the same buttons, and a snapshot keeps them across a restart. send_preset is one command, so a Stream Deck button reaches it through the HTTP API. Sending the same preset twice leaves rev alone. set_presets replaces the list. It drops empty entries and trims text at 120 characters. It keeps at most 8, because the row has to stay readable on a tablet. Verified against a live server. One click in the console put the note on the viewer with its tone.
Give a room an audible end. The viewer plays three short beeps as the timer crosses into overtime. A speaker facing away from the screen still notices that. The chime starts off. A room with three viewers should not beep three times, so an operator turns it on where it belongs. One screen can carry it alone with ?sound=1 while the room setting stays off. Only the crossing rings. A viewer that joins a room already in overtime stays silent, because a chime then reports nothing new. That rule is one function with its own tests, since it is the part worth getting right. A browser blocks sound until someone interacts with the page. The viewer therefore builds its audio context on the first click, key or touch. It also shows a button to tap while the chime is on and sound stays locked. Without that hint an operator cannot tell a silent screen from a broken one. WebAudio generates the tone, so the binary carries no audio file. The envelope ramps both ends, or the speaker clicks. Verified against a live server. The hint appeared when the chime went on and a click cleared it. The timer crossed zero with no console errors. The tone itself needs a real listener.
Stop the moving parts for anyone who asked the system for less motion. The overtime blink and the flash both animate. A flashing screen is the exact thing that setting exists to stop. The blink drops to a steady red, which still reads as overtime. The flash holds one dim frame rather than strobing, so the cue survives without the strobe. Transitions and the button press effect go too. Color carries every signal on its own, so nothing here loses meaning when the motion stops.
Add /<room>/agenda for the people who need the running order rather than the clock. It lists every cue with a projected start and end time. It marks the cue that is on now and dims the ones already finished. Backstage and the speakers both ask the same question, which is when they are on. Ontime answers it with role specific views, and this is the smallest version of that. The page needs no token, because a green room screen cannot type one. Clock times follow the running cue rather than a fixed plan. A session that overruns moves every later cue with it, which is the number a speaker actually wants. A cue that has run out chains the rest from now, because nothing can start in the past. The projection is a pure function with its own tests. Clock arithmetic across midnight is easy to get wrong and hard to notice. Verified against a live server. A running rundown showed the active cue in the header and the following cues at their projected times. The finished cue read as done.
A running order usually starts life in a spreadsheet. Add CSV and JSON export, and an import that replaces the cue list from either. The CSV reader accepts a header in any column order. It maps the spellings a spreadsheet is likely to carry. So cue, presenter and length all land in the right place. A duration takes minutes, mm:ss or hh:mm:ss. An empty one falls back to five minutes. Quoting follows the usual rules, because a cue title holding a comma is normal. A doubled quote reads as one quote. Export quotes only the fields that need it, so a diff of two exports stays readable. An import either replaces the whole list or changes nothing. A bad duration or a missing title returns the line number. The room keeps the order it had. That matters when the file came from someone else minutes before doors. set_cues assigns fresh ids rather than reusing the old ones. A console holding the previous list cannot then load a cue that moved. Export stays open like the viewer. Import is a write, so it wants the token. Verified against a live server. A spreadsheet style CSV imported and exported byte for byte. It then imported again into a second room.
Let the clock start a cue. An operator arms a wall clock time and the timer waits at the top. The autopilot starts it when the time arrives. Doors open at a time, not on a press. A room set up half an hour early should run itself at the appointed minute. Nobody has to stand at the laptop for it. The armed time lives on the timer, so a snapshot keeps it across a restart. Arming resets the run, since a cue that waits for the clock should start from its full duration. Starting by hand cancels the pending start. So do reset and loading a cue. Without that rule an armed time fires again later and restarts a talk that already ran. The autopilot already scans for a cue that reached zero, so arming needs no new machinery. A time already past fires on the next pass rather than failing. That is what an operator means by a time they typed late. The console takes hh:mm on a 24 hour clock. It picks the next occurrence, so a late evening arming lands tomorrow rather than in the past. That rule is a pure function with tests, because an off by one day here would strand a show. The viewer counts down to the start, so a stage display shows the wait. Verified against a live server. An armed room started within a fifth of a second of its time and cleared the armed value.
A room saved before a field existed failed to load. load_all skips a file it cannot read. An operator restarting after an upgrade lost the room without being told. Every field of the saved state now carries a serde default. A missing field takes its default rather than failing the whole file. A snapshot from before presets existed comes back with the standard presets rather than an empty row. Found by the test that ships with this change. It loads a snapshot written by hand in the older shape. That is the only way to catch this before a user does.
Add an auxiliary timer. It carries a label and runs on its own clock. The viewer shows it under the main readout. A break needs its own countdown while the session timer keeps running. Ontime carries an aux timer for the same reason. Until now an operator had to choose which of the two to show. The aux timer reuses the main Timer type rather than a parallel one. The readout math, the overtime rule and the browser side all come free. The two timers cannot drift apart in how they behave. Its thresholds start at zero, so a break does not turn amber on its way down. An operator who wants that can set them. Both directions carry tests. Aux commands never move the main timer. Main commands never move the aux timer. That is the mistake this shape invites. The viewer hides the aux timer until an operator shows it, and ?aux=0 hides it on one screen. Verified against a live server. Both timers counted down together from different start times, three seconds apart.
Let a command arrive as query parameters, so a controller that can only issue a GET reaches every operator action. Some hardware and some Companion buttons cannot send a body. The method alone kept those setups out of the API. Values convert by name rather than by shape. A field that holds text stays text, so a message reading 5 does not arrive as a number. Any other field takes a number when it parses as one, then true or false, then text. The endpoint shares the command type and the auth check with the POST form. There is one place where a command turns into state. Verified against a live server. Start, set_duration, message, flash and blackout all landed over GET, and an unknown name returned 400.
Message presets and reduced motion support both landed, and the backlog still showed them open. The edits meant to tick them matched nothing and passed silently. The list drives what gets built next. A wrong box there sends the next iteration at work that is already done.
Add two ways to put a room back to nothing. clear_room resets every part of the state. DELETE drops the room and its snapshot too. A room accumulates a title, a rundown and a message from the last event in it. The next event then starts with someone else's setup on screen. A shared server also collects rooms nobody uses again. Clearing keeps rev climbing rather than resetting it. A client compares revisions. A counter that jumps backwards would read as a stale frame, and the screen would keep the old show. Delete clears the room before dropping it. A screen still connected then sees an empty room, not the last state of a room that has gone. Delete is idempotent. Removing a room that is not there answers removed: false rather than failing, which suits a script. Both want the token, since both throw work away. The console carries the clear button behind a confirmation, and the picker offers delete per room. Verified against a live server. Clearing reset a room with a rundown and a snapshot. Deleting removed both the room and its file.
Put the speaker beside the title and the cue note under the next up line. Both come from the loaded cue, so a confidence monitor carries what the rundown already holds. The viewer reads them from the rundown rather than from a copy on the display state. Editing a cue therefore changes the screen at once, and there is one place where a cue title lives. The speaker starts visible. The note starts hidden, because a note often addresses the crew. Nobody wants switch to camera two on the stage screen. Both take a per screen override, so a booth monitor can carry the note while the stage feed does not. Verified against a live server. A loaded cue put the speaker on screen, enabling notes revealed the note, and hiding the speaker cleared it.
Document the two new display fields and their per screen overrides. Say which one starts visible. The code landed in the previous commit without its documentation. The edit meant to carry both died before it ran.
Announce the server as _scm._tcp.local. A phone or a laptop on the same network then finds it without anyone reading an IP address aloud. Reading an address off a laptop screen to a room is the worst part of setting this up. The spec left this as an open question, and the core works now. --name sets the name a browser shows. It defaults to the port, so two servers on one network stay apart. --no-mdns turns the announcement off. A network that blocks multicast logs a warning and keeps serving. Discovery is a convenience, not a dependency. The advertisement lives for the life of the process. Dropping it withdraws the service, so a server that stops does not linger in a browser list. Verified against a live server. dns-sd found Main Stage on two interfaces. That is an independent tool, not the crate this code uses.
Give a toggle its own look. Blue now means the button does something. Teal means the button is on. Every lit button was the same blue. Clock, Progress and Speaker read like Start and Show. An operator could not tell a state from an action at a glance. That is the wrong thing to be unsure about during a talk. A check mark carries the on state as well as the color. The meaning then survives a colorblind operator and a badly calibrated projector. Blackout keeps red, since it is a state nobody wants by accident. A tone button shows its own color, so the selected tone matches what the viewer displays. The show button stays blue and gains a ring, because it both acts and reports. Verified in a browser against a room with every toggle set.
Lay the console out in columns rather than a grid. Give every button row an explicit column count. A grid row is as tall as its tallest panel. A short panel therefore held open dead space beside a tall one. On a wide screen a third of the console was empty. Column flow packs each panel under the one above it. The nudge row was the worst of it. Four buttons at a flexible width put plus one minute on a line of its own. That moved a button an operator reaches for under pressure. Four equal columns cannot do that. The timer chips, the tone buttons, the toggles and the presets all get the same treatment. The rundown still spans the full width, since a cue list reads badly in a narrow column. Verified in a browser at 1400 and at 420 pixels. Nothing wraps oddly at either width, and the phone width falls to one column.
Add scale, title and next to the per screen overrides. A booth monitor can now differ from the stage feed in size and wording. Until now it could only differ in which parts it showed. Two screens rarely want the same thing. A monitor two metres from a speaker needs larger digits than a projector at the back. A booth screen wants its own label, and often no next up line at all. scale takes a percent. It clamps to the same 50 and 200 the room does. One URL cannot ask for something the console cannot undo. title replaces the text rather than hiding it. An empty title blanks the line. A title of zero stays the text zero, which a flag could not express. Parsing moved into one tested function. The viewer had grown an inline reader that no test covered, and each new override made that worse. Verified against a live server. One screen showed a replaced title at 140 percent with no next line. The plain URL showed the room values.
Add a preset editor. Each row carries the text, a tone and a remove button, with add, save and cancel below. set_presets already existed, but only over the API. An operator setting up a room had to reach for curl. That is the wrong tool for changing the buttons they press most, minutes before doors. The editor keeps its own copy while open. An arriving frame therefore cannot overwrite half typed text. That is the same rule the other text fields follow. Cancel discards, and nothing reaches the room until save. A new row starts with placeholder text rather than empty. Save drops an empty row, so an operator who adds one would watch it vanish. Verified against a live server. Adding, removing and changing a tone all survived a save, and cancel left the room untouched.
Add a soak test that reports how far a client's estimate of the server clock strays. Add unit tests for the estimator itself. The whole sync design rests on that estimate. A viewer renders between state frames from its own clock plus an offset. An offset that drifts therefore shows up as a timer that lies. Nothing measured it until now. make soak runs 40 samples over ten seconds against a live server. It prints mean, spread and drift. On loopback the offset holds inside two milliseconds, a fraction of a display frame. It stays out of the normal suite, since ten seconds is too long and the numbers depend on the network. The estimator moved into two pure functions, so a test can feed it jitter. One stalled response used to be able to drag the clock. The median covers that now, with a test to prove it. A venue network is still the number that matters. The README says to run the soak there before a show. One test expectation was wrong on the first pass. The median of an even count takes the lower middle. That is what the code did, and what the test name said.
Turn the announcement off by default. --mdns turns it on, and --no-mdns is gone. A process that multicasts unasked surprises people. On a managed laptop it also reads as beaconing, and endpoint security flags the binary. Discovery is a convenience for a venue, so a venue can ask for it. The name flag now only matters with --mdns. Verified on the default path. The server logs no announcement, holds no socket on port 5353, and listens only where --bind says. The advertise path keeps its own test.
Record that the queue is empty, and what the final verification covered. A file of ticked boxes says nothing about whether the thing runs. The release build passed a live check of the auth gates and the CSV round trip. It also covered snapshot restore across a restart and room deletion. The soak measured 0.11ms mean offset and 1.5ms spread on loopback.
Move the reference material into docs/ and cut the README to what a first-time reader needs. Drop SPEC.md and BACKLOG.md. The README had grown to 254 lines. It carried the whole command table, every flag, the CSV rules and the per-screen overrides. The two sentences saying what the thing is sat above a reference manual. The split under docs/: - operations.md: the API, the flags, and troubleshooting - architecture.md: the parts as built, and the decisions worth knowing - development.md: the layout, the suites, and the two rules that keep the frontend honest - release.md: cutting a release SPEC.md proposed the thing before it existed. Every part of it that still matters now lives in docs/architecture.md. A design doc that outlives its design drifts, and the log records the milestones it tracked. BACKLOG.md is an empty queue. The list did its job, and git log records what shipped. Also stop tracking .DS_Store, and ignore it along with .claude/.
Add the CI and release workflows, the Docker image, and a check that keeps the two build matrices in step. Modelled on the AmberDAV setup, without the framebuffer and SDL variants this project has no use for. CI runs on every pull request and every push to main. It covers formatting, clippy with warnings denied, and the Rust tests. It also runs a syntax check over each frontend script and the JavaScript suites. It then builds all six release targets. A cross-compile break lands on the pull request rather than at release time. Publishing a release builds each asset and attaches it. It then generates SHA256SUMS from the uploaded assets. Last it pushes a multi-architecture image to GHCR. The tag is the version. The build stamps it into Cargo.toml and Cargo.lock before compiling, so the binary reports the release. Without that it would report whatever the manifest last carried. The stamp step guards an empty tag and a tag that is not a plain version. A release event can fire before GitHub's state settles and carry no tag name. Without a guard that surfaces three steps later as an opaque cargo error. Both Linux assets link statically against musl, cross-linked with Zig. Neither carries a libc dependency. macOS and Windows build on their own runners, each cross-compiling the second architecture. The matrix appears in both workflows and in docs/release.md. A check compares the two matrices and fails when they drift. Verified in both directions. The image is Alpine holding the static binary. It runs as a non-root user, serves on 8080, and keeps snapshots in /data. It carries tzdata, which the wall clock and the local midnight calculation need. Verified locally. Both workflows parse and the matrix check agrees on six targets. The stamp step produces a Cargo pair that satisfies --locked. The image builds clean and runs as scm with a writable /data.
Rename tests/aux.rs to tests/aux_timer.rs. AUX is a reserved device name on Windows, and a file cannot carry it with any extension. Both Windows jobs failed in actions/checkout, before the toolchain even installed. The name arrived with the auxiliary timer. Nothing noticed until CI ran on a Windows runner for the first time. The new name also says what the file covers.
Add a screenshot of the stage display, the console and the agenda. The files live under docs/images, which keeps the root directory clean. A timer is a visual thing. Anyone deciding whether this fits their event wants to see the screen a speaker looks at. No amount of prose does that job. Also move the tap-to-enable-sound hint to the top of the viewer. It sat on top of the message overlay. The two fought over the same corner while the chime was on and audio stayed locked.
Turn the duration branch into a match guard. Clippy on CI flagged the nested if inside the arm. Local clippy was 1.94 and CI installs the latest stable, which is 1.97.1 today. Clippy gains lints with each release, so a clean local run proved nothing about CI. The guard also reads better. An empty duration now falls through to the arm that ignores a column, which is what the old branch did. Verified against CI's own toolchain. Clippy, fmt and the 244 tests pass on 1.97.1, and on 1.94 as well. The development guide now says to check that way.
Editing the cue on air no longer stops the timer. A cue edit refreshes the title, the next-up line and the target duration. It leaves the transport alone. update_cue called load_cue whenever the edited cue was the active one. That picked up a new title. It also reset the run and zeroed the elapsed time, which is right when an operator loads a cue. It is wrong when someone fixes a typo. A script correcting the notes on the running cue stopped the stage timer and threw the speaker's elapsed time away. Loading a cue still starts from zero. The two behaviours now live in separate functions, and three tests hold the line between them. Found by a review of this branch.
The CSV reader now walks the whole document rather than one line at a time. A quoted field can hold a newline, which is what a spreadsheet writes for a note of two lines. Export already quoted an embedded newline. Import split on line breaks first, so the halves became separate rows. A cue with a two-line note came back as two cues. One of them was a phantom, titled with the second half of the note. The import returned 200, because both halves parsed. The documented round trip through a spreadsheet destroyed data and said nothing. An error still reports a line number. It now points at the line the row starts on, rather than counting rows. Found by a review of this branch.
A restart now stops both timers. The main timer already came back paused at its saved elapsed time, and the auxiliary timer did not. An aux timer that was running came back running, still anchored to the instant before the crash. Its readout then included the whole outage, and it kept counting with nobody driving it. The documented rule covers any timer that was running. Found by a review of this branch.
Delete now retires the room. Its sockets close, and it refuses further commands. Delete dropped the registry entry and left the sockets running. A client still held an Arc to the room. It kept sending commands, and its subscribers kept seeing the results. Any new request created a fresh room under the same name. One console then drove a room that no reconnecting stage display could reach, while HTTP reported the empty replacement. An operator who deletes the wrong room now sees the console drop. It reconnects to an empty room, which is what the delete meant. Found by a review of this branch.
A read now validates the room name without bringing a room into being. Only a write or a socket creates one. Every GET went through a helper that created the room as a side effect. A typo in a viewer URL left a name in the registry and in the picker. So did a crawler walking the server. Deleting those wanted the token, while creating them wanted nothing. Reading a room that is not there now answers with the defaults. That is what a viewer of an idle room needs anyway. The GET command endpoint also converted a value by shape rather than by name. A speaker called 1234 came back as 400 with a serde type error. So did a note reading 0, and a label of true. Three more field names now stay text: speaker, notes and on_expire. The agenda repainted on a change of id, state, start or title only. Editing the last cue's length, or any speaker, left the table stale until something else moved. Found by a review of this branch.
Four fixes to the pipeline that a review of this branch turned up.
The pull request image carried sha-${github.sha}. On a pull_request
event that is the merge commit. cleanup-pr.yml deletes sha-${head.sha}
instead. The two never matched. Every closed pull request therefore left
an orphaned image behind, and delete-untagged does not reach a tagged
manifest. The build now tags the head commit, which is the one cleanup
looks for.
The release tag guard accepted digits and dots only, so v1.0.0-rc1
failed the whole already-published release. It now takes a semver
pre-release or build suffix, and still rejects v1.2 and a word.
CI had no top-level permissions block, so every job ran with the default
token scope. It now reads contents, and the docker job still asks for
packages.
The image put the port and the state directory in CMD. Docker replaces
CMD with any argument after the image name. The documented run command
passes --token, so it silently dropped --state-dir and wrote rooms
nowhere. Both now sit in ENTRYPOINT, where a user argument is added
rather than substituted. The README and the release guide said the
opposite, and now match.
Auto advance now loads the next cue and starts it in one step, and publishes one frame. It took the lock twice, once to load and once to start. An operator command landing between the two acted on a half-advanced room. A client could also see the next cue stopped at its full duration. That frame arrived a fifth of a second before the start. The window was small, and the two-frame flicker was not. Both are gone. Found by a review of this branch.
Two rules moved, so the guides move with them. A read no longer creates a room. A command or a connected socket does, and reading a room that is not there answers with the defaults. A delete now closes the sockets on that room. A console watching it drops and reconnects to an empty room.
rustfmt splits the assert onto its own lines. A fmt run landed after the commit. The change sat in the working tree, so CI checked the unformatted version and failed.
One file to paste into a Portainer stack, or to run with docker compose. It pulls the published image, keeps rooms in a named volume, and health checks the server. SCM_TOKEN is required, and the stack refuses to deploy without it. A server with an open console is not what anyone wants on a network. SCM_TAG, SCM_PORT and TZ all have defaults, and the tag defaults to the pull request image. The token arrives through the environment rather than an argument, so it stays out of the process list. Verified against the published image. The stack came up healthy. The console answered 401 without the token and 200 with it. The snapshot landed in the volume, and a room survived a restart of the stack.
An unclosed quote is now an error naming the line it opened on. The parser tracked quote state across the whole document and never checked it at the end. One stray quote therefore turned every remaining row into part of one field. A running order of four cues imported as one cue. The rest of the file sat inside its title, and the import returned 200. The bug it replaced damaged a single line. This one ate the remainder of the file, which is worse. Three tests cover the stray quote, the quote left open at the end, and the quote that closes properly. Found by a second review pass over the fix.
Move the agenda repaint signature into shared.js and cover it. Widening the signature was the one fix in the review round with no test behind it. It sat inside a draw function, so nothing could reach it. A later edit could drop a field and no suite would notice. Nine cases now assert that a change to any field the table shows produces a new signature. One more holds an unchanged table to its own signature. Found by a second review pass over the fix.
Two narrow fixes to the release workflow. The tag guard allowed one suffix, so 1.0.0-rc.1+build.5 failed while 1.0.0-rc1 passed. It now takes a pre-release and build metadata together, and still rejects v1.2 and a word. The docker job tagged latest on every release. Now that a pre-release can reach it, cutting v1.1.0-rc1 would have moved latest onto a release candidate. A pre-release tag now publishes its own version tag only. Found by a second review pass over the fix.
The port and the state directory move from the image ENTRYPOINT to environment variables. Every flag now reads one, so a stack can configure the server either way. Putting them in ENTRYPOINT stopped an argument from replacing them, which was the point. It also made an override fatal. A flag given twice is an error, so docker run with --port died at startup. The documentation invited exactly that. The previous shape dropped the defaults instead. Neither was right. An environment default loses to a command line argument, so both work now. Verified in the real image on three runs. No arguments keeps 8080 and /data. A --port 9000 serves on 9000 rather than crashing. A --state-dir moves the snapshot, and --token still gates the console. Found by a second review pass over the fix.
The stack variable becomes SCM_HOST_PORT. It maps a port on the host, and it never reaches the container. SCM_PORT means the port the server listens on, and the image sets it. The same name on the left side of a port mapping gave one name two meanings. That belongs least in a file someone pastes into Portainer and deploys. The release guide now says which is which. Verified with the published image. The stack came up healthy on the host port. The console answered 401 without the token and 200 with it.
The tag guard drops the plus suffix it accepted last round. Build metadata is valid semver, and it cannot be a Docker tag. A release tagged v1.0.0+build would have published binaries and checksums first. The image would then fail, leaving a half-finished release. Refusing it in the first step of the first job means nothing publishes. The error says why. A pre-release tag still works, since a hyphen is legal in both.
The picker showed its link panel on load, holding a broken image and two empty fields. A panel carries display: grid, and the browser hides a [hidden] element with a rule of equal weight. The class won on source order, so the attribute did nothing. The panel therefore appeared before any room name existed. The QR image had no source, and rendered as a broken icon. A single [hidden] rule with display none now sits at the top of each stylesheet. Every other hidden element already carried its own rule, and this was the one that did not. The global rule means the next one cannot repeat it. Verified in a browser. The panel stays hidden on load. A room name brings it back with a scannable code and both links. Reported from a live deployment. I had seen the same broken image in my own screenshot earlier and blamed the browser harness for it.
A browser opening the console without the token now gets a form. Entering it lands on the console and stores the cookie. Every other room on the server then opens without asking again. The refusal was a dead end. It said "this room needs the operator token" as plain text on a black page, with nowhere to put one. The only way in was to know that ?token= works and to type it into the address bar by hand. Clicking a console link from the picker hit that wall, which is where anyone starts. A wrong token brings the form back saying so. It also takes the bad value out of the address bar, so a refresh cannot resubmit it. An API caller still gets the plain refusal, since a form is no use to curl. Five tests cover the pair: HTML with a form for the console, text for POST, GET and DELETE. Reported from a live deployment with volunteers on it.
An edit to the rundown now refreshes the title and the next-up line. Deleting the loaded cue clears both. Those two lines come from the loaded cue, and only a load ever wrote them. Deleting every cue therefore left the last title and next-up frozen on the stage display. The timer below them had no rundown behind it. An operator clearing the list for a plain timer could only fix it by typing over both fields. The same staleness covered every list edit. Deleting the cue that was next left the old name on screen. So did renaming it, reordering the list, or adding a cue after the last one. A loaded cue now owns the screen, so the lines follow the list. Without one the fields belong to whoever typed them, and a rundown edit leaves them alone. Eight tests cover both halves. Reported from a live deployment.
Two things a first-time operator reached for and did not find. Both land in the same console files, so they arrive together. Next and go loads the following cue and starts it, on a button and on the g key. A plain load still leaves the timer stopped. That is the convention every product in the survey follows. An operator lines up the next talk while the current one runs. A load that started the clock would wreck the running cue. Auto advance already loaded and started in one step, though. A human pressing Next got only half of that. The inconsistency was mine, not the convention's. The command applies both halves under one lock, so it lands in one revision and one frame. At the end of the list it does nothing, matching auto advance, which leaves the last cue running into overtime. Edit opens a cue in place. The form carries its title, speaker, length and note. update_cue has existed since the rundown shipped, and only the API could reach it. The row holds its own copy while open, so an arriving frame cannot overwrite half typed text, and Cancel discards. Six tests cover the walk, the empty rundown, the first cue with nothing loaded, and the no-op at the end. Both paths verified in a browser, on the button, on the key, and through the form.
A JSON document now carries duration in the shape the CSV uses. Every command that sets a length accepts it too. The two exports disagreed. A CSV said 5:30 while the JSON said 330000, and the JSON carried an id the CSV left out. Nobody could edit the pair interchangeably. The JSON document now writes title, speaker, duration and notes, and nothing else. Either file rebuilds the same running order. An import reads whichever spelling arrives. So do add_cue, update_cue, set_duration and aux_set_duration. A clock value takes 7:30 or 1:02:03. A bare number under a thousand is minutes, since nobody sets a talk to half a second. A thousand or more stays milliseconds, so duration_ms keeps working and an existing script keeps running. Live state still reports duration_ms as a number. The browser does arithmetic on it every frame, and parsing a clock string there would buy nothing. adjust keeps its milliseconds. A delta is not a duration, and -30000 there means half a minute off. The add cue form hinted 10:00 while a blank field gave five minutes. The hint now says 5:00, which is what a blank field does.
set_thresholds now takes 3:00, a bare 3 for minutes, or milliseconds. It reads warn and danger as spellings of warn_ms and danger_ms. A threshold is a length. Leaving it out of the previous commit meant warn_ms: 3 still asked for three milliseconds. That is the same nonsense the duration rule exists to stop. Zero still turns a threshold off. The rule only converts a number above zero, so nothing about disabling one changes. schedule_start keeps its milliseconds. It carries a clock reading rather than a length, and an epoch value is far above the boundary anyway.
A bind mount arrives with the host ownership. The user the release image runs as often cannot write to /data. Every snapshot then failed with one warning per save, long after the operator stopped reading the log. The rooms looked persistent and the directory stayed empty. Opening the store now writes and removes a probe file, and a failure is fatal. The message names the directory and both fixes. Chown it to the user the container runs as, or set `user:` to match the directory. The docs lead with the `user:` override. Chowning a host directory to the image uid 100 hands it to a system account. That account means something else on a real distro. Docker's own mechanism needs no privileges here, and the server writes nothing outside the state directory, so any uid works. The store also opens once now instead of twice.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The first release of SimpleConfidenceMonitor: a speaker timer and confidence
monitor in one Rust binary, with the documentation and the release pipeline
that ship it.
What changed
/<room>, anoperator console at
/<room>/edit, and a read-only agenda. Countdown,count-up and time-of-day timers, warning thresholds, overtime, a rundown with
auto-advance, messages with one-press presets, a second timer, blackout,
flash, a chime at zero, and per-screen overrides.
every operator action is an HTTP call over POST or GET. Optional mDNS
discovery, off by default.
docs/holds operations, architecture, development andrelease, with screenshots of the three screens in the README.
multi-architecture GHCR image. A check keeps the two build matrices in step.
Per-change rationale is in the commit bodies.
Why
The four products in this space are all hosted services or larger installs. A
small event needs one binary on a laptop, no database and no internet, and a
setup step no longer than opening a URL.
The server owns the clock and each browser estimates its offset from it, so the
digits stay smooth and a display that loses the network keeps counting. That
choice drives most of the rest of the design, and
docs/architecture.mdrecordsthe alternatives that lost.
Review round
A review of this branch found eleven defects, all now fixed with a regression
test each. The four that could ruin a live session:
time, because a cue edit reloaded the cue.
export-edit-import round trip, splitting one cue into two with no error.
downtime, instead of paused.
no reconnecting display could reach.
The rest: an unauthenticated read created rooms without bound, the GET command
API rejected a numeric speaker or note, auto-advance was visible half-applied,
the agenda missed some repaints, the PR image tag never matched what the cleanup
job deletes, a pre-release tag would have failed a published release, CI ran
without a least-privilege token, and the documented
docker runsilentlydisabled the persistence it claimed to provide.
Volunteer testing round
Running the branch for volunteers found more, all fixed here:
The picker showed a broken QR image before a room had a name.
next-and-go control and an inline cue editor. Deleting every cue left the
stage display holding the last title and next-up line.
now covers both, and every command: under a thousand means minutes, and a
clock reading is a clock reading. Deltas stay milliseconds.
save and an empty directory. That is now fatal at startup, and the docs give
the ownership recipe for a bind mount.
How it was tested
309 Rust tests and 85 JavaScript tests,
clippy -D warningsandcargo fmtclean against CI's own toolchain (1.97.1), and the matrix-sync check.
Beyond the suites, a release build was driven live: the auth gates, a CSV round
trip through a spreadsheet-style file, snapshot restore across a restart with a
running timer coming back paused, room deletion, and auto-advance stepping a cue
on its own.
make soakmeasured the clock offset at 0.11 ms mean and 1.5 msspread on loopback.
The release image was built from a genuine static musl binary and run: the auth
gates behave, it runs as a non-root user, it writes its snapshot into the
mounted volume, and a room survives a container restart when
--tokenis passed— which is the case the Docker fix was for.
Two things need a real environment rather than this one. The chime needs a
listener, and the clock drift figure needs a venue network. Both are called out
in the docs.