Spec — Match Control console (central operator page)¶
Replace the per-event match-control.html with one operator console at
tools/match-control.html, driven entirely by event-data/config/events.json.
It keeps all four original tabs (Live Matches, Match Finder, Standings, Mission
Control) but carries no per-event configuration of its own: it picks an event,
reads that event's registry entry, and renders. A fifth tab, Awards, was added
later per awards-podium-tab-spec.md — a read-only podium/export view
derived the same way, sitting between Standings and Mission Control.
Per-event match-control.html then disappears from both templates and from
the instantiation runbook.
Status: built and deployed. The console is live at
tools/control-center.html — the console was renamed after this spec was
written, and tools/match-control.html remains only as a redirect stub for
bookmarked links. The go-live override is implemented end to end in
sage-tools-api, and AUTH_PASSWORD_HASH/AUTH_TOKEN_SECRET are set as
Cloud Run env vars in production.
Everywhere below that says match-control.html, read control-center.html.
Operators sign in (§4) rather than pasting a shared secret, and the auto
go-live threshold (§2.5, §4.1) is computed from the schedule rather than a
per-event config value — both described in place below, not as a config
field named LIVE_GO_LIVE_HOUR_PH.
1. Why central, not per-event¶
| Path | tools/match-control.html |
| Public URL | /tools/match-control |
| Discoverability | Unlisted — not linked from the tools index (§9.1) |
Three findings made this worth doing, all checked against the shipped code and the live snapshot rather than assumed:
- The organizer panel is already event-agnostic. Grepping the whole
Mission Control region for the presentation config returns nothing: it
touches none of
DIVISIONS,EVENTS,CLUBS,STAGE_META,parseCodeordivisionLabel. Its entire dependency surface isEVENT_KEY,DAYS[].{key,label},FACILITIES[].nameandCLOUD_RUN_BASE_URL. - The registry it needs already exists, and is publicly readable.
GET https://sage-match-control.github.io/event-data/config/events.jsonreturns200 application/jsonand lists every registered event, its days and its facility names. Read is public; only the actions are secret-gated. So the console needs no new config surface to enumerate events — and no secret to show anything. - The shared secret is global.
SYNC_SHARED_SECRETis a single Cloud Run env var (sage-tools-api/index.mjs), checked insrc/sync/routes.mjs. Today it is stored per page as${EVENT_KEY}.orgSecret, so an operator running two events pastes the same secret twice. One console = paste once.
1.1 What it replaces¶
match-control.html is index.html plus the Mission Control tab, minus the
QR panel, with computeDayIsLive() hardcoded to true. There are four copies
(~13,700 lines) and each shares a ~106-line configuration block with its
sibling index.html — the duplication CLAUDE.md already names as this repo's
known weak spot. All four go away.
index.htmlis not affected. It stays per-event, keeps its own config block, and keeps its club logos. It is the public, spectator-facing page; this is the operator's.
2. Configuration — events.json is the only source¶
2.1 New fields¶
"pnf-x-bup-dual-meet": {
"type": "dual-meet", // NEW — picks layout + team-code shape
"archived": false, // NEW — true hides it from the picker (§9)
"title": "PNF × BUP Dual Meet", // NEW — masthead
"days": {
"pnf-x-bup-day1": {
"label": "Sep 12", // unchanged, already present
"date": "2026-09-12", // NEW — see §2.5
"isLive": "auto", // NEW — operator-controlled, see §4.1
"facilities": [ ... ] // unchanged
}
},
"display": { // NEW — entirely optional (§2.3)
"divisions": { "LI": "Low Intermediate", "HI": "High Intermediate", "A": "Advanced" },
"events": { "WD": "Women's Doubles", "MD": "Men's Doubles", "XD": "Mixed Doubles" },
"clubs": { "PNF": "Pickle & Friends Community", "BUP": "1Bataan United Picklers" }
}
}
type is "dual-meet" or "standard", matching the two templates. It decides
both the standings layout (per-club columns + cross-club Bronze/Final, vs flat
category cards) and the team-code shape (<CLUB>_<DIV><EVT>_<REST> vs
<DIV><EVT>_<REST>).
typemust be explicit, not inferred. DistinguishingA_B_CfromA_Bby counting segments works most of the time and fails silently — unmatched codes currently fall back to "Other" with no warning, which is the dangerous kind of failure. An unknown or missingtypeis an error the console shows, not a guess.
All three display maps are the same shape: code → label. One resolver
handles all three. Ordering comes from JSON key order, so DIVISION_ORDER /
EVENT_ORDER disappear entirely — a simplification over today.
No logos. display.clubs is a plain string map, not an object. The club is
already on every row as its 3-letter code; the live table would otherwise
render 18 logos at once (9 courts × 2 sides) carrying no information the
adjacent tag doesn't. Dropping them is also what keeps clubs the same shape
as divisions/events, and it sidesteps the asset-path question entirely
(logos live in events/<key>/assets/, which moves when an event is archived).
2.2 Derived from the snapshot, not configured¶
Verified against the live pnf-x-bup-day1.json:
| Thing | Today | Derivation |
|---|---|---|
| Facility → courts | hand-written FACILITIES[].courts: [1,9] |
Each facility ships its own CSV, so its court set is knowable. Recovered courts 1..9 with no config. |
| Club set | CLUBS keys |
1st code segment — BUP, PNF (2 distinct across 126 matches) |
| Category set | DIVISIONS × EVENTS |
2nd code segment — 7 distinct |
| Stage (RR/QF/SF/…) | STAGE_META |
code tail; the regexes are generic, not per-event |
| Standings grouping | divisionEventLabel() |
group on the whole category token |
| Day label | DAYS[].label |
snapshot's own label field |
| Courts in play, scores, players, live state | — | data |
Facility → courts is the one that is better centralised. A hand-written range can drift from what the sheet actually contains; a derived one cannot.
2.3 Not derivable — and what happens without it¶
Only four things genuinely need display:
LIWD→ "Low Intermediate Women's Doubles"PNF→ "Pickle & Friends Community"- Deliberate category order
- The event title
The first cannot be inferred: splitting LIWD into LI+WD requires the
vocabulary, since LIW+D is equally valid to a parser. That is exactly why
CODE_REGEX is built from the config today.
But the split is needed only for labels and ordering, never for structure.
Grouping on the whole LIWD token produces byte-identical groups to today.
So: no display block → the console still works, showing raw codes
(LIWD, PNF) and first-appearance ordering. A newly registered event is
usable immediately; display is polish added when convenient.
2.4 Resolution failure must be loud¶
With display.divisions present, split a category token by longest-prefix
match against the division keys, then map both halves.
This is ambiguous only when one division key prefixes another and both
remainders are valid events (divisions A/AM with events MD/D). Rare,
but:
After resolving, assert every code mapped. If any did not, show a visible warning naming them. Do not inherit the current silent "Other" fallback — a mislabelled board that looks fine is worse than one that says it is wrong.
2.5 Day scheduling — date and isLive¶
date moves here because the console needs it: label: "Sep 12" is not
machine-sortable, and the console has to order days chronologically and default
to the one being played.
isLive moves here for a different reason — it stops being a config value
edited by hand and becomes an operator control in the console (§4.1). This
section defines where the value lives; §4.1 defines the button.
3. The tabs¶
Live Matches, Match Finder and Standings behave as they do today, with three differences:
- No club logos (§2.1) — the
.score-logo,.live-team-logoand.cs-logotreatments are not carried over. The hero's.club-logospair is not carried over either. - Always live. The console ignores
isLiveand the go-live hour, as today'smatch-control.htmldoes (computeDayIsLive()returnstrue), so an organizer can preview a day before it opens publicly. - Labels degrade to codes when
displayis absent (§2.3).
Standings layout is chosen by type: dual-meet gets the per-club columns,
the club win summary and cross-club Bronze/Final; standard gets flat
category cards with the toggle bar and mobile category filter.
3.1 Theme — SAGE, and built that way from the start¶
The console is a tool, and it lives in tools/ beside
scoresheet-generator.html and tournament-calculator.html, which are where
the S.A.G.E. palette came from in the first place. It uses that palette
directly: navy structure, green accent, off-white paper, Archivo Black /
Barlow Condensed / Inter, the same :root token block those two share with
schedule.html and (since the recent conversion) both event templates.
Two consequences worth stating up front:
- Build it SAGE-native, don't port and convert. The per-event
match-control.htmlit replaces was originally a dark page that was converted; converting a second time would inherit the artefacts of the first pass. The console is a new file — write it against the tokens. - It does not take on per-event theming. An event may re-skin its own
index.html(runbook §2 step 5), but the console looks the same whichever event is selected. It is one tool that happens to be pointed at different data, and an operator switching events should not see the furniture move.
Carry over the two rules the conversion established, since they are easy to get wrong on a fresh page:
Green is a fill, not a text colour.
--greenon white is ~2.3:1 and fails AA at any size;--green-darkis ~4.0:1 and still misses the 4.5:1 body floor. Use green as a background with--navytext on it (~5.4:1), or on a navy panel. Small text on paper is--inkor--ink-soft.The masthead is a navy bezel on paper, matching the tools and the schedule board. Controls sitting on it are light-on-navy and need their own scoped rules — the shared control styles are written for the paper ground of the body.
schedule.html'sHERO-SCOPED CONTROL OVERRIDESblock is the worked example.
Responsiveness gets the same treatment as the schedule board: the console will
be opened on a phone at a venue, so the masthead wraps rather than forcing the
document wider than the viewport, and the live table stays readable at 375px.
See schedule-screen-spec.md and the NARROW SCREENS block in
schedule.html.
That held for the live table but not for everything around it. Three things were overflowing their containers on a phone and have since been fixed:
- The view-tab row. Five pills need ~400px in a row, so under roughly a
440px viewport the first and last tabs were clipped by the hero and could
not be tapped at all — Live Matches and Mission Control were simply
unreachable. It is now a horizontal scroller below the breakpoint, with
the leading edge fading only while there is more to scroll toward and the
newly activated tab scrolled into view.
justify-contenthas to flip toflex-startthere: a centred flex row that overflows makes its leading items unreachable in every browser, which is the same bug at the other end. - The standings club-summary boxes, whose contents were
flex:0 0 autoinside aflex:1box, so nothing could give and each box spilled past its own border. - Mission Control's status rows at 320px, which now wrap the detail onto its own line rather than truncating the facility name.
Tap targets are sized off @media (pointer:coarse) rather than a width
breakpoint, so a tablet gets them too. The organizer secondary buttons had
padding:0 16px with no vertical padding, leaving them 16px tall — awkward
with a mouse and unusable with a thumb.
The desktop standings layout the console started with — a 4-column grid
that wanted ~1100px inside the page's 920px .wrap, plus the "Advanced
overflow column" §8 checks for — has been replaced. On desktop the board spans
the full viewport width, as one horizontally scrolling row of fixed 360px
category columns. Pair names never wrap: each table scrolls sideways on its
own. A Round Robin split into brackets shows one mini table per bracket,
two to a row in a double-width column; a 1 col toggle in that category's
header stacks them in a single normal-width column instead.
The built layout is in
Control Center technical.
4. Mission Control¶
Unchanged in behaviour, but scoped to the selected event:
- Per-facility sync status (
Synced 4h ago/Never synced/Last attempt failed), read from the loaded snapshot — no extra fetch. Check connection,Resync this day now, optional per-facility resync (shown only when the event has more than one facility).Use CSV export fallback instead of Sheets APItoggle.Open schedulelauncher →/events/<event-key>/schedule.html. This is the schedule board's only entry point and it moves here from the per-event page.
One sign-in covers every event. The console has an operator sign-in form
(username + password, one shared combo for the whole team) that calls POST
/auth/login. The API checks the pair against a stored hash and returns a
short-lived signed token; the console holds that token under a single
sage.authToken key (not per event), never the credentials or the raw
SYNC_SHARED_SECRET. In a browser tab the key lives in sessionStorage, so
closing the tab signs out. Installed as an app (display-mode: standalone)
it lives in localStorage instead: iOS starts a fresh session whenever it
reloads a home-screen app it dropped from memory, which would clear
sessionStorage and sign the operator out long before the token expires.
Either way the token's own expiry ends the sign-in, and Sign out clears
both stores. Signed-out operators still get read access —
schedule, live matches, standings, match finder — and only the actions
(resync, go-live override) are gated on the token. See
sage-tools-api/src/auth/AuthService.mjs.
4.1 Go-live override — the kill switch as a button¶
isLive is documented in the code today as "a kill switch if something needs
to come down mid-day", but pulling it means editing index.html, committing,
and waiting for Jekyll to rebuild the site repo. That is the wrong shape for
something you reach for under pressure. It becomes a control here instead.
Three states, per day — not a binary. Returning to auto is the normal
end state after an incident, so the UI must offer it explicitly:
| State | Public site shows | Use |
|---|---|---|
auto |
live 4 hours before the day's earliest scheduled match time (read from the synced Schedule column, date for the day) |
the default |
true |
live now, regardless of schedule | opening early |
false |
Live/Standings hidden, scores suppressed | the kill switch |
How it takes effect¶
The write path already exists and needs no new plumbing:
GitHubPublisher.publish(path, json, message, knownSha)is generic — it is already the shared writer for bothconfig/events.jsonand the day snapshots.SyncServicealready re-reads config on every sync and rebuilds the snapshot from it (labelcomes fromconfig.getDay(day)this way).
So: events.json is the source of truth, and the published snapshot is a
mirror. SyncService stamps the day's current isLive into every snapshot
it writes, exactly as it already does with label. index.html then gets the
flag from the file it already polls — no second fetch, no second poll, no new
failure mode.
A new secret-gated endpoint does both halves:
- Write the new value into
config/events.json. - Immediately republish that day's snapshot so the change lands on the public site within one 10s poll.
Step 2 is the whole point. Without it the flag would only reach the public site the next time somebody edited the spreadsheet — a kill switch that waits for an unrelated event is not a kill switch. This is also why the flag cannot live only in the snapshot: a snapshot is a derived artifact, and a rebuild from scratch would lose the operator's intent.
Because the value is sourced from config and re-stamped on every sync, a later sync cannot silently revert an override — a property worth an explicit test (§8).
UI requirements¶
- Show the current effective state prominently, and for
auto, when it will flip ("Auto — - goes live 7:00 AM, 12 Sep"). A forgottenfalsethat is not visible is the main failure mode of this feature. - Say plainly that this controls the public site, not the console. The
console ignores
isLivefor its own display (§3), so an operator who hides a day and sees no change in front of them will otherwise assume the button is broken. - Confirm before
false. It is the one destructive state.
This feature does nothing until
index.htmlreads the flag from the snapshot. The console button and that change ship together or not at all; a toggle that silently has no effect is worse than no toggle.
5. Event and day selection¶
- Event list comes from
events.jsonover plain HTTPS with the same?t=${Date.now()}cache-buster the snapshot fetches use — GitHub Pages caches for ~10 minutes otherwise. - Selection rides in the URL (
?event=<key>&day=<key>) so an operator can bookmark straight into the event they are running, matching how the schedule board carries?courts=and?compact=. - Last selection also remembered in
localStoragefor a bare visit.
sage-tools-api'sSYNC_CONFIG_TTL_MSdoes not apply here. That is the server's own cache ofevents.json; a browser fetching the file directly gets it fresh.
6. What leaves the templates¶
- Delete
_templates/dual-meet-template/match-control.htmland_templates/standard-tournament-template/match-control.html. {{CLOUD_RUN_BASE_URL}}disappears from the runbook's token table — it was used only bymatch-control.html, and the console is a single file that can hardcode it.- Runbook §2 loses the match-control half of step 4 (config block) and the
Mission Control references; the runbook's §3 token table loses one row; its §5.1 hand-sync
table loses its
match-control.htmlcolumn. index.htmlgains a preview escape hatch (§9.1).
6.1 Cutover, given the console runs PNF × BUP on 2026-09-12¶
The decision (§9, decision 2) is to cut over before the event, so the console's first real use is a live tournament. Two rules make that safe without slowing it down:
- Keep
events/pnf-x-bup-dual-meet/match-control.htmlin place through the event. Deleting it is build step 7 and costs nothing to defer. Leaving it means that if the console misbehaves mid-event, the operator has a known-good page at a URL they already have — no rollback, no deploy, just a different bookmark. Delete it once the event is over. - Delete the two template copies only after that. They are what a future event instantiates from, so nothing forces their removal on this timeline, and keeping them costs only tidiness.
This is not a hedge against the decision — the console still becomes the tool used to run the event. It just means the first live use has a fallback that requires no action to reach.
Rehearse before the day: run a real resync through the console against the live event, confirm the sync status rows match what the per-event page shows, and exercise the go-live override end to end (§8) while it still doesn't matter.
7. Build steps¶
- Add
type,title,date,isLiveanddisplayto both events inevent-data'sconfig/events.json; updateconfig/README.mdfor the new shape. - Build
tools/match-control.html: config resolution layer (§2) first, proven against both events, then the four tabs, SAGE-themed from the start (§3.1). - Verify against the live snapshot at both
typevalues, and with thedisplayblock removed, to confirm the degradation path (§2.3). - Go-live override (§4.1), as one unit:
SyncServicestamps the day'sisLiveinto every published snapshot, alongsidelabel.POST /sync/:day/liveinsage-tools-api— writes config, then republishes that day's snapshot.index.html(both templates) readsisLivefrom the snapshot instead of its ownDAYSentry; there is noLIVE_GO_LIVE_HOUR_PHfield — theautothreshold is computed from the Schedule column (§2.5, §4.1).- The console's control.
- Add the preview hatch to
index.htmlin both templates (§9.1). - Delete the two template
match-control.htmlcopies; update the runbook (§6). - Delete
events/pnf-x-bup-dual-meet/match-control.htmlonce the console has been used successfully for that event.
8. Acceptance¶
-
/tools/match-controllists both registered events with no per-event code. - Selecting
pnf-x-bup-dual-meetrenders 130 matches, 84 standings rows, 7 category columns, the Advanced overflow column, and 7 cross-club Bronze/Final blocks — matching today's per-event page. - Facility → court mapping is derived, not configured, and matches
courts 1..9. - Removing the
displayblock leaves everything working, showingLIWDandPNFas raw codes. - A code that fails to resolve produces a visible warning, not "Other".
- An event with
typemissing or unknown shows an error, not a guess. - The secret is entered once and works for every event in the session.
-
Open scheduleopens the selected event's board in a new tab. -
?event=&day=round-trips through a reload. - No club logos anywhere on the console.
- Scores are visible for a day that has not reached its go-live hour.
Go-live override (§4.1):
- All three states are reachable, and the current one is shown;
autoalso states when it will flip. - Setting
falsehides Live/Standings on the publicindex.htmlwithin one poll (~10s), without a manual resync or a sheet edit. - Setting it back to
autorestores the scheduled behaviour. - A subsequent sync does not revert the override — set
false, edit the sheet to trigger a real sync, confirm the day is still hidden. - The console's own tabs stay visible while a day is force-hidden, and the UI says the control affects the public site.
-
POST /sync/:day/liverejects a request with a missing or wrongX-Sync-Secret. - Selecting an event whose snapshot is missing shows a clear "not published" state, not a blank board or a stuck spinner (§9, decision 4).
- Multi-facility rendering exercised. No live data currently exists for
this:
bkl-cup-2026is registered with daysday2–day6, but every one of its snapshots returns 404, andpnf-x-bup-dual-meethas a single facility. Test against a hand-built fixture, or defer this until an event with more than one venue actually runs.
9. Decisions taken¶
- Full scope in v1. The console and the go-live override ship together,
including the
sage-tools-apiendpoint, theSyncServicechange, the Cloud Run deploy, and both templates'index.htmlreading the flag. - Cut over before 2026-09-12, so PNF × BUP is run from the console. See §6 for how that is de-risked.
- No preview hatch on
index.html. The console ignoresisLiveby design, so organizers preview there. This removes the?preview=1question entirely — no guessable URL undermining the embargo thatisLiveexists to enforce.index.html's only change is reading the flag (§4.1). - Dead registry entries get
"archived": trueand are filtered out of the event picker. The "not published" state is still built, as the fallback for a day that is registered and current but has not synced yet.
9.1 Defaults applied without further asking¶
All reversible; say so if any is wrong:
- A snapshot with no
isLivefield (anything synced before this ships) is treated asauto, so existing events keep their scheduled behaviour rather than going dark. - The console is not linked from any tools index.
- No in-page audit trail. Every override writes a commit to
event-data, sogit log config/events.jsonalready records who changed what and when. displaylabels living in the data repo is treated as confirmed — it is already written into the runbook at your request.
10. Still open¶
- ~~Multi-facility rendering has no live test case.~~ Closed. Pickle for
Sight (27 September 2026) published two venues, PCPH Main and PCPH Annex,
into one snapshot, and the
facility progress spec's §5 checks ran the
console's per-facility Live board against it
(
?event=pickle-for-sight-2026&day=pickle-for-sight-day1). That snapshot stays published and is the multi-facility test case.
11. Out of scope¶
- Any change to
index.html's look, config, or club logos, beyond the two functional ones it needs: readingisLivefrom the snapshot (§4.1) and the preview hatch (§9.1). ItsDIVISIONS/EVENTS/CLUBSblock, its layout and its logos all stay exactly as they are. - The schedule board. It stays per-event: it needs
CAT_META, which is organiser-owned presentation read off the source spreadsheet, and it is tied to one venue and one day. bracket-generator.html. Already self-contained, no config, no sheet access.- Moving presentation config out of
index.html. The public pages keep their own config block; only the console is data-driven.