Spec — Push for attendance¶
When someone is marked in at one desk, every other desk and Control Center's Attendance tab show it within a second or two, instead of within about 20 seconds. Pages stop polling: they read the list when they open, when they come back into view, when a nudge arrives, and when someone presses Refresh.
The live Worker pushes a nudge, a message that says only this facility's list changed. It carries no names and no check-in state, so it can travel on a public socket. Each page that gets one re-reads the list from the API with its own token, as it does today. The API's reads from Google stay as they are.
Status: implemented. Built in
sage-tools-api3.4.0 (the API and the live Worker), the site'slib/v1(data/attendance-signal.js, the attendance view, the desk page, Control Center and the attendance template) and docs 2.12.0. Merged intomainon 2026-10-13, with the site going out in three pushes (§1 D10). The Worker was deployed by hand on 2026-10-18. Written 2026-10-12 againstsage-tools-api3.3.0. It is tested against fakes and a local Worker; the owner's checks in §10.2 (P1–P7) and §6 step 4, which needs a Google workbook, had not run when it moved here. As built: a read of everything starts its 11 s follow-up when its reply arrives, not when it is sent (§5.2). A nudge that arrives while a page is hidden still gets its settle call 11 s later, which reads the list if the page is visible again by then (§5.1, D7). The people route's OpenAPI description and_templates/CLAUDE.md§7 (turnLIVE_BASE_URLoff in a finished event's desk page too) were brought in line, though §0.2 does not list them.Builds on Live push delivery and Attendance for every event, whose D4 kept attendance off the live channel because the channel is public. This spec keeps that rule: nothing private goes over the socket. Read their §0 if a term here is unfamiliar.
0. Read this first¶
You are implementing this with no other context. Everything you need is in this page and the files it names. Read §0 to §2 in full before writing code, then do §3 to §6 in order. §10 is what to report when you finish.
0.1 The system in one paragraph¶
Each venue on each day has a Google Sheet (a facility workbook) with an ATTENDANCE
tab. sage-tools-api (Node/Express on Cloud Run) reads and writes that tab as its
service account. Desk staff open a desk link on a phone (the desk page,
events/<key>/attendance.html) and operators use Control Center's Attendance tab.
Both render lib/v1/views/attendance-view.js, which reads one facility's people with
GET /v3/days/{day}/facilities/{facility}/people every 10 s while the page is visible,
and marks someone with PUT …/people/{personKey}/attendance. The API caches the list
for 10 s per workbook, per instance. Separately, the Cloudflare Worker sage-live
holds one Durable Object per <event>/<day> that stores the day's public snapshot and
pushes it to every open page over wss://…/live/<event>/<day>. Cloud Run publishes to
it with a shared secret.
0.2 Where things are¶
D:\Personal\SAGE is a plain folder holding four git repos. Elsewhere (a cloud
checkout, say), it is whichever folder holds the four repos side by side, and the
workspace CLAUDE.md this spec calls D:\Personal\SAGE\CLAUDE.md is the one in the
sage-workspace checkout beside them. Every path below is relative to that folder.
| Path | What you change there |
|---|---|
sage-tools-api/live-worker/ |
§3: two routes on the Worker, the DayChannel object, smoke.mjs, README.md |
sage-tools-api/ |
§4: LivePublisher.nudge, AttendanceService sends nudges, a port, wiring, tests, version, Changelog |
sage-match-control.github.io/ |
§5: a new lib/v1/data/attendance-signal.js, attendance-view.js, lib/v1/css/attendance.css, attendance-desk.js, tools/control-center.html, the attendance template, _tests/ |
sage-docs/ |
§7: feature and technical pages |
D:\Personal\SAGE\CLAUDE.md |
§7: three sentences (not in any repo) |
event-data/, every apps-script/*.gs file and the finished events' desk pages are
not touched (§8).
0.3 Hard rules¶
- Commit to a feature branch, never to
main. In each repo you change, create one branch (the same name in every repo, e.g.attendance-push, unless your session names one for you), commit there in small steps with clear messages, and push it. No pull request, no merge, no rebase ofmain, no force-push. The owner reviews the branches, merges and deploys. The workspaceCLAUDE.mdfollows its own repo (sage-workspace) the same way. - Never deploy the Worker (
npx wrangler deploy), never call the production API or the production Worker, and never touch the realevents.json. Run the Worker withnpx wrangler dev; tests use fakes. Deploys are the owner's (§10.2). - Write each test first and watch it fail, then make it pass. Never weaken or delete a test. No existing assertion should need to change; if one does, stop and report it.
sage-tools-api: ESM.mjs, classes with constructor injection, no new dependency, no TypeScript, 4-space indent, double quotes, comments that say why. Obey the dependency rules inD:\Personal\SAGE\CLAUDE.md(enforced bytest/unit/guards/dependency-rules.test.mjs):AttendanceServicereaches the Worker only through the port injected insrc/app.mjs.- The Worker: plain JS, the style of
live-worker/src/index.js. No new Durable Object class, so no new entry inwrangler.jsonc'smigrations(§1 D2). - Site: no new dependency, 2-space indent, single quotes, match the surrounding style.
lib/v1changes must be compatible (_templates/CLAUDE.md§8): add, never remove or rename. A browser can hold a 10-minute-old copy of one file beside a new copy of another, so the site change ships in three pushes (§1 D10). Write the code so each push is safe on its own. - Find functions by name (
grep -n "function name("), not by line number. - Line endings: keep whatever each file has today (the files named here are LF).
- Cite this spec from code comments as
sage-docs/docs/specs/.../attendance-push-spec.md, with literally.... - Documentation is present tense: what the system does, not what changed.
- If the code contradicts this spec in a way that blocks you, stop and report it (§10). Don't work around it.
0.4 Facts checked against the code (2026-10-12)¶
| Fact | Where |
|---|---|
The Worker routes /live/<event>/<day>, /snapshot/… and /publish/… (one ROUTE regex); the object is idFromName(\${event}/${day}`)withlocationHint: "apac-se".liveneedsUpgrade: websocketand an allowedOrigin(originAllowed);snapshotandpublishneedX-Publish-Secret(secretOk, constant-time) |live-worker/src/index.js,fetch` |
|
DayChannel.fetch dispatches on the path's first segment. #connect accepts a hibernatable socket and sends {type:"snapshot",…} or {type:"empty"} at once. #publish broadcasts to this.ctx.getWebSockets(). The constructor sets a ping→pong auto-response |
live-worker/src/index.js, DayChannel |
wrangler.jsonc binds DAY_CHANNEL to DayChannel, migration tag v1 |
live-worker/wrangler.jsonc |
Every WebSocket client ignores a message whose type isn't snapshot (if(msg.type !== 'snapshot' …) return;) in lib/v1/data/live-channel.js and in the inline copies in events/pickledrive-anniversary-2026/{index,schedule}.html and events/piggleball-2026/{index,schedule}.html |
those files |
live-channel.js exports LIVE_PING_MS (50000), LIVE_PONG_TIMEOUT_MS (10000), LIVE_SAFETY_POLL_MS, LIVE_RETRY_MAX_MS (30000) and createLiveChannel |
lib/v1/data/live-channel.js |
LivePublisher has enabled (URL and secret both set), read, publish, a private #request(method, kind, event, day, body = null) that sends X-Publish-Secret, times out at timeoutMs (4000) and derives the word in its error messages from the method (GET → read, anything else → publish), and #failure(what, res) → Error("live Worker <what> failed: HTTP <status> <body>") |
src/clients/LivePublisher.mjs |
smoke.mjs uses one throwaway key, `smoke-test/run-${Date.now()}`, which is the event and the day together (event smoke-test, day run-<ms>), so a URL is `${base}/live/${key}`. It has helpers report(step, ok, detail) and nextMessage(ws, ms), numbered steps 1–6, and ws.close() after step 6 |
live-worker/smoke.mjs |
config.livePushOn is raw.livePush !== false (the Sync method switch); SnapshotPublisher.liveActive(config) is livePublisher.enabled && config.livePushOn |
src/registry/SyncConfigSnapshot.mjs, src/sync/publishing/SnapshotPublisher.mjs |
AttendanceService.mark writes only when planMark(…).changed, then this.peopleCache.delete(facility.sheetId). #reconcileFacility deletes the entry after createAttendanceTab and after updateValues with rows. listPeople → #cachedPeople reuses a read for PEOPLE_CACHE_MS (10 000). this.now() returns a Date |
src/attendance/AttendanceService.mjs |
#reconcileFacility({ config, day, type, facility, standingsRows, force }) is not given the event. Both callers have it: reconcileAfterSync({ day, event, facilities }) as an argument, reconcileDay from config.getDay(day). reconcileAfterSync waits for all facilities at most RECONCILE_BUDGET_MS (6000) |
src/attendance/AttendanceService.mjs |
src/app.mjs constructs one LivePublisher (livePublisher) and new AttendanceService({ configStore, sheets, authService, logger }); test/helpers/fakes.mjs has FakeLivePublisher({ enabled, snapshot }). test/unit/app.test.mjs checks what createApp returns (app.attendanceService instanceof AttendanceService) |
src/app.mjs, test/helpers/fakes.mjs, test/unit/app.test.mjs |
AttendanceService.test.mjs builds the service with make(extra) (a fresh FakeSheets, which records writes, reads and created; a capturingLogger(), whose lines holds ["warn", message] pairs; now: () => NOW), and ...extra overrides any dependency |
test/unit/attendance/AttendanceService.test.mjs |
attendance-routes.test.mjs does not go through createApp: it serves the real routes with startApp({ services }) (test/helpers/http.mjs). Its last block, a desk link issued to someone, end to end, builds a real AttendanceService over FakeSheets and marks through PUT /v3/…/attendance |
test/integration/attendance-routes.test.mjs |
createAttendanceView(opts) polls with setInterval(() => { if(!document.hidden) load(true); }, opts.pollMs) and loads on visibilitychange; it returns { refresh, setShowWithdrawn, destroy }. load(quiet) fetches every facility in console mode and the chosen venue in desk mode, and drops its reply when st.loadSeq has moved on. A successful mark already calls load(true). The skeleton is box.append(venuesEl, bar, countEl, catbar, listEl), where bar holds the search box and Refresh |
lib/v1/views/attendance-view.js |
mountAttendanceDesk(settings) accepts only KNOWN_SETTINGS = ['eventKey']; any other name logs unknown setting and returns without rendering. It discards what createAttendanceView returns, and has EVENT_KEY, FIXTURE and info.day in scope |
lib/v1/apps/attendance-desk.js |
The attendance template's shell has no LIVE_BASE_URL; Control Center does ('wss://sage-live.sagematchcontrol.workers.dev') and already runs createLiveChannel for the day's snapshot. Its Attendance tab is built in attConsoleMount() (attConsoleView = createAttendanceView({…, event: CURRENT_EVENT_KEY, day: day.key, …})) and torn down only in attConsoleDestroy(), which every exit calls (another tab, a day or event change through syncAttendanceTab, a remount). ATTENDANCE_POLL_MS is imported from platform.js and used only as that pollMs |
_templates/attendance/attendance.html, tools/control-center.html |
The finished events' desk pages (Pickle for Sight, PickleDrive, Piggleball) are inline copies that do not import the engine: PickleDrive's and Piggleball's carry their own inline createAttendanceView and poll with their own ATTENDANCE_POLL_MS. No live page imports attendance-desk.js today; only the template does |
events/*/attendance.html |
Site test rules: _tests/unit/guards.test.mjs lets views/ import only views/ and domain/, and data/ import platform.js, domain/ and data/, so the view never imports the signal; the app and Control Center wire the two together. _tests/unit/views/attendance-view.test.mjs has no DOM and covers only attendanceCsvUrl, fetchPeople and markPerson. _tests/unit/apps/settings.test.mjs checks each mount's unknown and required settings. _tests/unit/data/data-modules.test.mjs has a FakeSocket class and a liveSetup(t) that installs it as globalThis.WebSocket under mock.timers |
_tests/unit/ |
The comparison harness answers every sage-live socket by closing it at once (page.routeWebSocket(/sage-live/, ws => ws.close()) in _tests/helpers/browser.mjs) and installs a fake clock, so no timer fires unless a step moves it. Its attendance cases are attendance/<event>/… (the template's desk page, not in fixture mode), cc/<event>/attendance/… (signed out, already accepted as row 28) and cc-signed-in/<event>/attendance/… |
_tests/helpers/browser.mjs, _tests/engine/cases.mjs, _tests/engine/accepted.mjs |
npm test in sage-tools-api, in a Linux checkout on 2026-10-12: 4 failures, all the HTML snapshots in test/unit/scoresheets/TemplateService.test.mjs, unrelated to this spec (§6 step 1 says what to do with them). The site's _tests npm test: all pass |
— |
1. Decisions (settled with the owner, 2026-10-12)¶
| # | Decision |
|---|---|
| D1 | The nudge carries no data. The message is exactly {"type":"attendance","facility":"<name>"}. No person, key, name, state, time or count. A facility's name is already public in events.json. Pages re-read the list from the API with their own token |
| D2 | Its own object, not the day's snapshot channel. The Worker gives attendance a separate object, idFromName(\attendance:${event}/${day}`), of the **same**DayChannelclass (no migration). It stores nothing. Public viewers never receive a nudge, a desk never receives a snapshot, and a fault here can't reach the public feed. Event and day keys match[a-z0-9-], so theattendance:` prefix can't collide with a snapshot object's name |
| D3 | Two Worker routes. GET /attendance/<event>/<day> is the socket: Upgrade: websocket, the same Origin rule as /live, no secret, nothing sent on connect. POST /nudge/<event>/<day> with body { facility } needs X-Publish-Secret, broadcasts the D1 message to that object's sockets and answers 200 {clients}. A facility that isn't a 1–100 character string is 400 |
| D4 | The API nudges after every write that changes the tab, and only then: a mark whose plan changed, a reconcile that created the tab, and a reconcile that wrote rows. One nudge per write, after the write and the cache drop |
| D5 | Best effort, awaited, short. The nudge is awaited before the response, because Cloud Run throttles CPU once a response is sent, with a 2-second timeout. A failure or timeout logs one warning and never changes the answer or throws |
| D6 | Nudges follow the environment, not the Sync method switch. The API nudges whenever LivePublisher.enabled (URL and secret set), whatever config.livePushOn says. With no polling (D8), a nudge is the only way other pages hear of a mark, and the switch is about how snapshots are delivered. After a failed nudge the service sends none for 60 s (per instance), so a Worker outage costs a mark at most one 2-second wait a minute |
| D7 | Pages coalesce. A nudge for a facility the page shows re-reads that facility only, at most once every 2 s (trailing). And once more 11 s after the last nudge, because another Cloud Run instance can hold a list up to 10 s old (PEOPLE_CACHE_MS), so the follow-up is never stale. Nudges for a facility the page doesn't show are ignored. While the page is hidden, nudges are ignored; the existing visibilitychange load catches up |
| D8 | No polling. The owner's call: no timer re-reads the list. A page reads it when it opens, when it comes back into view (visibilitychange: a phone that slept missed nudges), when its socket opens again after a drop (it missed nudges meanwhile), on a nudge (D7), and when someone presses the existing Refresh button. Each of those reads except a nudge's gets one follow-up read 11 s later (the owner's call, 2026-10-13): the read may be served from another instance's cache up to 10 s old, and a page that wasn't listening missed the nudges that would have corrected it. A newer such read restarts the 11 s; the follow-up itself schedules nothing, so this is never a poll. When the socket isn't open, the view says so in a line of its own: Live updates are off. Press Refresh to see marks from other devices. |
| D9 | Where it runs. The desk page (desks mode, from the template) and Control Center's Attendance tab. Off in fixture mode. A page with no Worker address gets no nudges and shows the D8 line. The finished events' inline desk pages are untouched and keep their own polling |
| D10 | Rollout order. (1) The Worker, deployed by hand, never within 3 days of an event. (2) The API: harmless before any page listens. (3) Site push A: attendance-signal.js (new) and attendance-view.js. (4) Site push B, at least 10 minutes after A: attendance-desk.js and tools/control-center.html start using them. (5) Site push C, at least 10 minutes after B: the attendance template passes the Worker address. A cached older attendance-desk.js refuses an unknown setting and renders nothing, which is why C waits |
| D11 | No read changes. GET …/people, its 10-second cache and its response stay exactly as they are. Google reads per facility don't go up |
| D12 | Versions. sage-tools-api 3.4.0 (minor: new behaviour, no new route). The Worker has no version; its README.md route table is its record |
2. Overview¶
desk A marks someone
PUT …/people/{personKey}/attendance ──> sage-tools-api ──> ATTENDANCE tab (write)
│ drop the list cache
│ POST /nudge/<event>/<day> {facility} (2 s, best effort)
v
sage-live: object "attendance:<event>/<day>"
│ {"type":"attendance","facility":"Gym"}
┌────────────────────────┼─────────────────────────┐
v v v
desk A desk B Control Center
(re-reads Gym: (re-reads Gym now, (re-reads Gym now,
sees its own mark) and again in 11 s) and again in 11 s)
└──── GET …/facilities/Gym/people with its own token ───┘
The cost is easy to see:
- Google reads: unchanged. Every re-read goes through the existing 10 s cache.
- API requests: none while nothing changes, instead of six a minute per page: a page that opens reads twice (now and 11 s later), then nothing until something happens. At most one re-read per 2 s per page during a burst of marks, plus one follow-up.
- Worker: one nudge per write that changed the tab. Each nudge goes to that day's attendance sockets only.
3. The Worker (sage-tools-api/live-worker/)¶
3.1 src/index.js, fetch¶
- Widen
ROUTEto^/(live|snapshot|publish|attendance|nudge)/(${KEY})/(${KEY})$. - Name the object by kind:
attendanceandnudgeuse`attendance:${event}/${day}`, the rest`${event}/${day}`as now. SamelocationHint. attendance: the same checks aslive(426without the upgrade header,403for a disallowedOrigin), thenstub.fetch(request). Today everything that isn'tlivefalls through to the secret check, so widen that branch's condition tokind === "live" || kind === "attendance"rather than adding a second copy.nudge: the same secret check aspublish(401),POSTonly (else405), thenstub.fetch(request). That is one more|| (kind === "nudge" && request.method === "POST")in the existing method check.
3.2 DayChannel.fetch¶
attendance→ a new#listen(): like#connect(create the pair,this.ctx.acceptWebSocket(server), answer101) but send nothing.nudge→ a new#nudge(request). Parse the JSON body (400 bad jsonon failure).facilitymust be a string of 1–100 characters, else400. BuildJSON.stringify({ type: "attendance", facility })and send it to every socket, as#publishdoes, ignoring a socket that throws. AnswerResponse.json({ clients }). Write nothing to storage.
#connect, #read and #publish are unchanged. Comment the two new methods with this
spec's path.
3.3 smoke.mjs¶
Add these as steps 7–12, after step 6 and before the existing ws.close(), on the same
key (so the URLs are `${wsBase}/attendance/${key}` and `${base}/nudge/${key}`).
ws is the step-2 /live socket, still open, its greeting already read. Add one helper
beside nextMessage: noMessage(ws, ms), which resolves true if no message arrives
within ms and false if one does.
- Open
const att = new WebSocket(`${wsBase}/attendance/${key}`), wait for itsopenevent, then: no message within 1 s (nothing is sent on connect). POST /nudge/${key}with the secret and{ "facility": "Smoke Court" }answers200withclients ≥ 1. Start listening on both sockets before sending it.attreceives exactly{"type":"attendance","facility":"Smoke Court"}within 2 s.ws(the/livesocket) receives nothing within 2 s of that nudge.POST /nudge/${key}without the secret is401; with the secret and{ "facility": "" }it is400; aGETwith the secret is405.att.send("ping")getspongwithin 3 s. Thenatt.close().
3.4 README.md¶
Add the two routes to the route table, with the rules in D1–D3, in the README's own style.
4. sage-tools-api¶
4.1 LivePublisher.nudge (src/clients/LivePublisher.mjs)¶
// One data-free nudge: "this facility's attendance list changed"
// (sage-docs/docs/specs/.../attendance-push-spec.md). -> { clients }.
// Throws on network errors, a 2-second timeout, 401, 400 and 5xx.
async nudge(event, day, facility) { … }
POST {baseUrl}/nudge/{event}/{day} with X-Publish-Secret, Content-Type:
application/json and the body { facility }. It times out after 2000 ms, not
timeoutMs. Don't duplicate #request: give it a sixth, optional argument
{ what, timeoutMs } = {}, where what defaults to what it derives from the method today
and timeoutMs to this.timeoutMs; read and publish keep calling it as they do. So
nudge calls this.#request("POST", "nudge", event, day, { facility }, { what: "nudge", timeoutMs: 2000 }),
throws await this.#failure("nudge", res) for any non-ok answer, and returns
{ clients: body.clients }. The messages are then live Worker nudge timed out after 2000ms
and live Worker nudge failed: …. Unlike publish, it logs nothing on success (one
line per mark would flood the log).
4.2 The port (src/shared/ports.mjs)¶
/**
* What AttendanceService needs to tell open pages a list changed
* (sage-docs/docs/specs/.../attendance-push-spec.md).
* @typedef {object} AttendanceSignals
* @property {boolean} enabled
* @property {(event: string, day: string, facility: string) => Promise<unknown>} nudge
*/
4.3 AttendanceService (src/attendance/AttendanceService.mjs)¶
- The constructor takes an optional
signals(AttendanceSignals, defaultnull). Type it in the JSDoc like the other dependencies. - The constructor stores
this.signals = signalsandthis.nudgeFailedAt = 0(ms since the epoch of the last failed nudge; per instance, in memory, like the caches). - A private
async #nudge(event, day, facilityName): - Return at once unless
this.signals?.enabled(D6). The registry'slivePushOnis not consulted, so#nudgetakes noconfig. - Return at once if
this.now().getTime() - this.nudgeFailedAt < NUDGE_BACKOFF_MS(60 000, a constant besidePEOPLE_CACHE_MS). - Otherwise
await this.signals.nudge(event, day, facilityName)insidetry. On any error, setthis.nudgeFailedAt = this.now().getTime(), logthis.logger.warn(`attendance ${day}/${facilityName}: live nudge failed: ${err?.message ?? err}`)and return. Never throw. mark: insideif (plan.changed), afterupdateValuesand the cache delete, callawait this.#nudge(event, day, facilityName)(eventis already in scope fromconfig.getDay(day)). The return value is unchanged.#reconcileFacility: addeventto its argument object, and pass it from both callers (reconcileAfterSynchas it as an argument,reconcileDayfromconfig.getDay(day)). Track whether this run calledcreateAttendanceTabor calledupdateValues(only done whendata.length > 0). If either, callawait this.#nudge(event, day, name)once, after the race re-check and its logging, whether or not it raced (the tab changed either way), before thereturn. A skipped reconcile (same fingerprint) and a run with nothing to write send nothing. A reconcile after a sync runs insideRECONCILE_BUDGET_MS. A failing nudge adds at most 2 s, once a minute; if that ever tips a run past the budget,reconcileAfterSynclogs and moves on as it already does. Change nothing there.
4.4 Wiring (src/app.mjs)¶
Pass signals: livePublisher to new AttendanceService({ … }). Nothing else is
constructed. livePublisher is constructed above attendanceService already, so no
lines move.
4.5 Test helpers¶
FakeLivePublisher (test/helpers/fakes.mjs) gains nudges (an array of
{ event, day, facility }), a nudge(event, day, facility) that records one, and
nudgeFails (an Error, default null) that makes nudge reject after recording.
4.6 New tests (write each first; watch it fail)¶
| File | Cases |
|---|---|
test/unit/clients/LivePublisher.test.mjs |
nudge: POST to …/nudge/<event>/<day> (URL-encoded), the secret header, the JSON body { facility }; 200 resolves to { clients }; 401 and 500 reject with live Worker nudge failed: HTTP …; a request that never answers rejects with timed out after 2000ms (use the file's existing timer approach), even when timeoutMs is 4000 |
test/unit/attendance/AttendanceService.test.mjs |
Build with make({ signals: new FakeLivePublisher() }). A changed mark nudges once with the event, the day and the facility name, after updateValues (a nudge that records sheets.writes.length when called sees the write already there). An unchanged mark sends no nudge. enabled: false sends none. An event in a registry with top-level livePush: false still nudges (build a second SyncConfigSnapshot from { ...RAW, livePush: false }). A rejecting nudge leaves the mark's return value as it was, logs exactly one warning containing live nudge failed, and doesn't throw. After it, a mark 59 s later sends no nudge, and one 60 s later does (pass now: () => current and move current). A reconcile that writes rows nudges once; one that creates the tab nudges once (not twice when it also writes rows); a skipped reconcile (the same roster again with force: false) and a forced one with nothing to write send none. A service built without signals marks and reconciles as before. Use the file's existing reconcileAfterSync/reconcileDay cases as the pattern for setting up each tab |
test/integration/attendance-routes.test.mjs |
In a new describe modelled on a desk link issued to someone, end to end (its own RAW, FakeSheets and startApp), with a real AttendanceService given signals: new FakeLivePublisher(): an operator's PUT /v3/…/attendance that changes someone records one nudge { event, day, facility }. The same PUT again records none. With nudgeFails set, the PUT still answers 200 with the same body as without it |
test/unit/app.test.mjs |
createApp gives the attendance service the app's LivePublisher: app.attendanceService.signals === app.livePublisher |
4.7 Version and Changelog¶
package.json 3.4.0. A ### 3.4.0 entry at the top of README.md's Changelog in
its usual shape, saying what pages now get and that nothing private crosses the socket.
It ends with a Tests: line naming the four test files and test/helpers/fakes.mjs.
5. The site (sage-match-control.github.io)¶
5.1 lib/v1/data/attendance-signal.js (new) — push A¶
// The attendance nudge (sage-docs/docs/specs/.../attendance-push-spec.md).
// One WebSocket per page to the Worker's attendance object for a day. A message says
// only which facility's list changed; the page re-reads it from the API.
import { LIVE_PING_MS, LIVE_PONG_TIMEOUT_MS, LIVE_RETRY_MAX_MS } from './live-channel.js';
export const ATTENDANCE_NUDGE_GAP_MS = 2000; // at most one re-read per facility this often
export const ATTENDANCE_SETTLE_MS = 11000; // sage-tools-api's PEOPLE_CACHE_MS (10 s) + 1 s
/**
* @param {{ baseUrl: string, enabled: boolean, event: string, day: string,
* onChange: (facility: string) => void,
* onStatus?: (open: boolean) => void }} options
* @returns {{ isOpen(): boolean, close(): void }}
*/
export function createAttendanceSignal(options){ … }
These imports are of exports that already exist, and the file is new, so it's safe in push A.
- The socket: connect to
`${baseUrl}/attendance/${event}/${day}`(both URL-encoded) whenenabled. MirrorcreateLiveChannelfor upkeep:pingeveryLIVE_PING_MS, close if nopongwithinLIVE_PONG_TIMEOUT_MS, reconnect with the same backoff (1000 · 2^retries, capped atLIVE_RETRY_MAX_MS, plus up to 1 s of jitter).close()stops everything for good. - Messages: ignore
pongand anything that isn't JSON withtype === 'attendance'and a stringfacility. - Coalescing (D7), per facility:
- Call
onChange(facility)at once if the last call for that facility was at leastATTENDANCE_NUDGE_GAP_MSago. Otherwise call it once when the gap ends. - After every nudge, (re)start that facility's settle timer:
onChange(facility)againATTENDANCE_SETTLE_MSafter the last nudge. - Status:
onStatus(true)when the socket opens;onStatus(false)when it closes or a connection attempt fails, once per change (not once per retry). The state starts unknown, so the first open or the first failure always reports. Withenabled: false, never create a socket and callonStatus(false)once, on the next tick (setTimeout(…, 0), so the caller has its view by then). isOpen()is!!ws && ws.readyState === WebSocket.OPEN, as increateLiveChannel.- After
close(), no callback fires again: clear the ping, pong, retry, gap and settle timers, and detach the socket's handlers before closing it. - No pause on a hidden page: the view ignores nudges while hidden (§5.2), and a socket that died while a phone slept reconnects through the normal backoff.
5.2 lib/v1/views/attendance-view.js — push A¶
- No timer without
pollMs. Today the interval is set whenever the view isn't in fixture mode, andsetInterval(fn, undefined)would fire constantly. Set it only whenopts.pollMsis a positive number. The apps stop passing it in push B (D8). Until then, a cached older app passes it and keeps polling, which is harmless. - The returned object gains
changed(facility): - If the page is hidden, or the facility isn't one this view loads (desk mode: the
chosen venue; console: any of
facilities), do nothing. - Otherwise do a quiet load of that facility only. Give
loadan optional second argument naming one facility:load(quiet, only), whose targets are[that facility]whenonlyis given and what they are today otherwise. - The
loadSeqrule must not drop the other facilities' data: only a newer load of the same facility supersedes it. Do it this way: keep the global counter, addst.facilitySeq = new Map()(facility name → the seq of the newest load that included it); a load records its seq for each of its targets before fetching, and on reply applies only the results whosest.facilitySeq.get(name) === seq. If none apply, it returns without rendering, as a superseded load does today. A full load records every target, so it supersedes any single-facility load still in flight, and vice versa for that one facility. - Someone this device is still saving keeps their local state, as a load already does.
- It also gains
setLive(open): falseshows the D8 line,truehides it. The line is its own element,el('div', 'att-live-off', 'Live updates are off. Press Refresh to see marks from other devices.'), placed straight afterbar(box.append(venuesEl, bar, liveOffEl, countEl, catbar, listEl)) and shown or hidden with itshiddenproperty. Add.att-live-offtolib/v1/css/attendance.cssbeside.att-status, in that file's one-line style: the same size as.att-status,color: var(--att-red), a small top margin. Adding a class is compatible. It isn't asay()message, because in Control Center those become toasts and this line has to stay.- When
setLive(true)follows asetLive(false), do one quiet load of everything the view shows (load(true)), to catch up on nudges missed while disconnected. The firstsetLive(true)loads nothing: the view's own first load covers it. - Before the first call the line is hidden.
- The follow-up read (D8). Give the view one timer,
st.followUp. After each read of everything the view shows that comes from the first load, avisibilitychangeload, the catch-up load insetLive(true), orrefresh()(the Refresh button, and Control Center's call after Update roster), clear it and start it again:setTimeout(() => { st.followUp = null; if(!document.hidden) load(true); }, FOLLOW_UP_MS). The follow-up's own load starts no timer, and neither does a nudge'schangedload (the signal schedules its own) or theload(true)after a mark.destroy()clears it. No timer in fixture mode, and none while signed out (a load that found no token).FOLLOW_UP_MS = 11000is a constant at the top of the view, commented as sage-tools-api'sPEOPLE_CACHE_MS+ 1 s, the same value asATTENDANCE_SETTLE_MS: the view may not importdata/(§0.4), so it keeps its own copy. - A successful mark keeps calling
load(true). The device that marked also gets its own nudge; the second read is served from the API's cache and costs nothing. - Update the header comment (
returns { refresh(), destroy(), setShowWithdrawn(v) }and thepollMsline) to describechanged,setLive, andpollMsas optional. - Apart from the follow-up timer, don't change the behaviour of
refresh, the Refresh button,destroy,visibilitychangeor optimistic marking.
5.3 lib/v1/apps/attendance-desk.js — push B¶
KNOWN_SETTINGSbecomes['eventKey', 'liveBaseUrl'](optional, default'').- After
createAttendanceView, keep the view it returns. ThencreateAttendanceSignal({ baseUrl: settings.liveBaseUrl, enabled: !!settings.liveBaseUrl && !FIXTURE, event: EVENT_KEY, day: info.day, onChange: f => view.changed && view.changed(f), onStatus: open => !FIXTURE && view.setLive && view.setLive(open) }). - Stop passing
pollMstocreateAttendanceView(D8). DropATTENDANCE_POLL_MSfrom the import if nothing else uses it. The export stays inplatform.js, since nothing is removed fromlib/v1. view.changed &&andview.setLive &&guard against a cached push-A-less view, which is harmless.- Update the header comment's usage line to
mountAttendanceDesk({ eventKey, liveBaseUrl })and describeliveBaseUrl(optional;''or absent: no nudges, and the page says live updates are off).
5.4 tools/control-center.html — push B¶
- Beside
let attConsoleView = null;addlet attConsoleSignal = null;. - In
attConsoleMount(), afterattConsoleView = createAttendanceView({…}), setattConsoleSignal = createAttendanceSignal({ baseUrl: LIVE_BASE_URL, enabled: !!LIVE_BASE_URL && !FIXTURE, event: CURRENT_EVENT_KEY, day: day.key, onChange: f => attConsoleView && attConsoleView.changed && attConsoleView.changed(f), onStatus: open => !FIXTURE && attConsoleView && attConsoleView.setLive && attConsoleView.setLive(open) }). Stop passingpollMs(D8), and dropATTENDANCE_POLL_MSfrom theplatform.jsimport, since nothing else in the page uses it (the export stays). - In
attConsoleDestroy(), close the signal before the view:if(attConsoleSignal){ attConsoleSignal.close(); attConsoleSignal = null; }. Every teardown goes through it, so nothing else needs to close it. Signing out doesn't tear the tab down: the signal stays open, a nudge's load finds no token and the view shows Sign in as it does today. - Import
createAttendanceSignalfrom/lib/v1/data/attendance-signal.js, beside thelive-channel.jsimport. - The day's existing
liveChannelis untouched.
5.5 _templates/attendance/attendance.html — push C¶
Add const LIVE_BASE_URL = 'wss://sage-live.sagematchcontrol.workers.dev'; beside
EVENT_KEY, the same literal as Control Center: a literal, not a {{TOKEN}}. Pass
liveBaseUrl: LIVE_BASE_URL to mountAttendanceDesk. The finished events' desk pages
aren't changed (D9). Jekyll doesn't publish _templates/, so this reaches a live page
only when an event is next instantiated from the template; the wait in D10 protects
that page.
5.6 _tests/¶
unit/data/data-modules.test.mjs, in the style of itscreateLiveChanneltests (theFakeSocketstub andmock.timers), forcreateAttendanceSignal:- the URL;
enabled: falsenever connects;- a nudge calls
onChangeat once; - a second nudge for the same facility within 2 s calls it once more, when the gap ends;
- two facilities don't share a gap;
- the settle call comes 11 s after the last nudge, and a nudge in between pushes it back;
- junk,
pongand othertypes are ignored; - reconnect backoff after a close;
close()stops reconnecting and pending calls;onStatusfires once per change of state, not once per retry, and once withfalsewhenenabledis false;isOpen(). Put them in a new// ---- attendance signal ----section after the live-channel tests, reusingFakeSocketwith a setup function likeliveSetupthat callscreateAttendanceSignaland recordsonChangeandonStatuscalls.unit/apps/settings.test.mjs: one case in the style of mountScheduleBoard knows "type":mountAttendanceDesk({ eventKey: 'e', liveBaseUrl: 'wss://x' })logs nounknown settingerror. Past the settings check the mount readslocation, which Node lacks, so it rejects withlocation is not defined: call it aserrorsFrom(() => mountAttendanceDesk({…}).catch(() => {}))and assert only that no error mentionsunknown setting.unit/views/attendance-view.test.mjshas no DOM, sochanged,setLive, the one-facility load and the absent interval can't be unit tested there. Add no DOM library (no new dependency). The harness (below) and §6 step 4 cover them; say so in your report.unit/guards.test.mjs,unit/live-pages.test.mjsandunit/no-copies.test.mjsmust still pass: the view imports nothing fromdata/, and no page defines its own copy of the signal.npm run verify: the comparison harness must show no difference on any page, apart from the D8 line. The harness closes everysage-livesocket at once (§0.4), so on the branch the line shows on every non-fixture attendance case: the desk page (attendance/…) and Control Center's tab signed in (cc-signed-in/…/attendance/…).cc/…/attendance/…is already accepted in full (row 28). Add one entry per new pattern toACCEPTEDin_tests/engine/accepted.mjs, under a heading comment// ---- push for attendance (sage-docs/docs/specs/.../attendance-push-spec.md §1 D8) ----, shaped like the rows there, for example{ case: /^attendance\//, kind: 'both', row: 'attendance-push D8', reason: 'with the live Worker unreachable the list says live updates are off' }. Accept only the cases that actually differ, and list them in your report. If a case relied on a poll to show a change, report it rather than adding a timer back. Fixture mode turns the signal off and shows no line. Under the harness's fake clock the follow-up timer fires only if a step moves the clock past 11 s, and then reads the same fake data, so it changes nothing on screen.
6. Checking it yourself¶
npm run verifyinsage-tools-api: passes, apart from any failure that also fails onmainin your checkout. Name those.npm run verifyinsage-match-control.github.io/_tests: passes.- In
sage-tools-api/live-worker:npm install(installs the existingwranglerdevDependency from its lockfile; not a new dependency), create.dev.varsholdingPUBLISH_SECRET=local-test-secret(git-ignored), runnpx wrangler dev, thennode smoke.mjs http://localhost:8787 local-test-secret: every check passes, the old and the §3.3 ones. Ifwrangler devcan't run where you are (no network for its runtime, say), report that and the smoke output you could get. - This one needs a local API (
npm startinsage-tools-apiwith a.envthat hasLIVE_PUSH_URL=http://localhost:8787,LIVE_PUSH_SECRET=local-test-secret, sign-in settings, and Google access to a scratch facility workbook) and the local Worker. If you don't have Google access or a scratch workbook, don't improvise one: say so in your report, and the owner's P3, P6 and P7 cover it. Otherwise serve the site from its root (python3 -m http.server 8000, or any static server; imports are root-absolute), and point the pages at the local services with temporary edits that you revert before reporting:CLOUD_RUN_BASE_URLinlib/v1/platform.jsbecomeshttp://localhost:8080, andLIVE_BASE_URLintools/control-center.htmland in a scratch copy of the desk page becomesws://localhost:8787(name the copyattendance-beta.html: the site's**/*beta*rule keeps it out of git). Open two desk pages and Control Center's Attendance tab for one day. - Mark someone on one desk. The other desk and the console show it within about 2 s, and each makes one more read about 11 s later.
- Open a page: it reads its list twice, at once and about 11 s later. With no marks after that, it never reads again, however long you wait.
- Press Refresh: one read, and one more about 11 s later. Press it again within those 11 s: the follow-up moves to 11 s after the second press (one, not two).
- Stop the Worker: each page shows Live updates are off…, and Refresh still works. Restart it: the pages reconnect, the line goes, and each page reads its list once, then once more about 11 s later.
- Hide a desk page, mark someone on another, show it again: it reads the list once, then once more about 11 s later.
grep -rn "attendance-signal" sage-match-control.github.io/events: no matches. Finished desk pages don't use it.
7. Documentation¶
| Page | What it says |
|---|---|
sage-docs/docs/technical/event-attendance.md |
Replace The live Worker is not used: its channel is public with the nudge: the separate object, the data-free message, D4–D8, and that nothing polls. Add the nudge to the flow diagram. A Push subsection after Reading the list |
sage-docs/docs/technical/sync-pipeline.md, Live push delivery |
One paragraph: the Worker also relays attendance nudges on a separate object per day; the snapshot channel is unchanged |
sage-docs/docs/technical/api.md |
No route changes. Under attendance, one sentence that a write that changes the tab nudges the Worker |
sage-docs/docs/technical/control-center.md |
The Attendance tab's attendance signal and when it is closed |
sage-docs/docs/features/event-attendance.md |
Marks made on other devices appear within a second or two. If the page says Live updates are off, press Refresh to see them |
sage-docs/docs/usage/desk-handout.md |
Marks made on other phones appear within about 10 seconds becomes within a second or two; if the page says Live updates are off, press Refresh |
sage-docs/docs/usage/run-the-day.md |
Under What to watch, the Live updates misbehaving bullet (the one that says to switch Sync method to GitHub only): one sentence that the switch doesn't change attendance; desks still get live updates while the Worker is up |
sage-docs/docs/changelog.md, mkdocs.yml |
A MINOR entry and the version, per the changelog's rules |
D:\Personal\SAGE\CLAUDE.md |
The data-flow note: the Worker also relays data-free attendance nudges (/attendance, /nudge). live-worker/'s layout entry: the two routes. src/attendance/: it nudges the Worker after a write |
8. What doesn't change¶
GET …/people, its cache, its response and its headers (D11). No route is added or removed, and/v1is frozen as always.- The day's snapshot channel:
/live,/snapshot,/publish,#connect,#publish,createLiveChannel, and every page that uses them. - The finished events' desk pages,
event-data/, everyapps-script/*.gs, andwrangler.jsonc(no migration, D2). - Google reads (D11).
9. Alternatives considered (for the record; don't build these)¶
| Alternative | Why not |
|---|---|
| Push the list itself over the socket | The socket is public (only Origin is checked, and that is trivially forged). Check-in state and shirt sizes are staff data |
| An authenticated attendance socket, the Worker verifying desk and operator tokens | The Worker would need the token keys, a second copy of AuthService's rules in another runtime, and their expiry rules kept in step. The data-free nudge needs none of it |
| The nudge on the day's existing snapshot channel | Every public viewer would receive every nudge, which also reveals when check-in activity happens. A fault in the new code would share an object with the public feed. It saves one socket per staff page |
| Server-Sent Events from Cloud Run | A long request per page keeps instances billed and busy, and a write on one instance can't reach a page connected to another |
| Polling as a fallback (every 10 s, or 30 s while connected) | The owner's call: no polling. The Refresh button, a re-read when the page comes back into view or reconnects, and the Live updates are off line cover the same gap |
| A shorter poll | More API requests with no fresher data: the 10 s cache sets the floor |
A ?since= on GET …/people that forces a fresh read |
A token holder could force a Google read per request; the 11 s follow-up gets the same freshness from the existing cache |
10. Finishing¶
10.1 Report back¶
- The branch name, and for each repo you changed (
sage-tools-api, the site,sage-docs,sage-workspace) its commits on that branch (git log --oneline main..), andgit status --shortshowing nothing left uncommitted. npm run verify's summary fromsage-tools-apiand from the site's_tests/.smoke.mjs's output againstwrangler dev.- §6's checks, each ticked or explained.
- Which site commits are push A, B and C (D10). Commit the site's work as at least three commits, one per push, in that order, so the owner can push them 10 minutes apart.
- Anything in this spec you found wrong or had to interpret, with what you did.
10.2 The owner's checks (not yours)¶
| # | Check | Pass |
|---|---|---|
| P1 | Deploy the Worker by hand (not within 3 days of an event); run smoke.mjs against it |
every check passes; the public Hub still updates live |
| P2 | Deploy the API; on a scratch event, mark someone from Control Center with no desk page open | the mark works; the log shows no live nudge failed |
| P3 | Push A; 10 minutes later push B; open a desk link and Control Center's Attendance tab | a mark on one shows on the other within about 2 s |
| P4 | Switch Sync method to GitHub only | marks still save, and other pages still show them within about 2 s; switch back |
| P5 | 10 minutes after B, push C; instantiate the template for a scratch event | its desk page connects and updates live |
| P6 | With two desks open, watch Cloud Run's requests to …/people for ten quiet minutes |
none after each page's first load and its follow-up 11 s later |
| P7 | Block the Worker on one desk's network (or stop it) | that desk shows Live updates are off; Refresh shows other desks' marks |