Control Center¶
One operator console (tools/control-center.html) covering every registered
event. The alternative it displaces is a separate copy of the console per
event, each carrying its own hand-duplicated configuration block — which is
why the console carries no per-event configuration of its own: it picks
an event from
event-data/config/events.json (see event registry
schema), reads that event's registry entry, and
renders. Its views are the site engine's: the Match
Finder, tickets, Live Matches, Standings, team views, score dialog and
attendance list come from lib/v1/views/ (and their CSS from lib/v1/css/),
the rules from lib/v1/domain/, so the console and an event's Tournament Hub
draw the same markup. The page itself keeps what only an operator has: the
event picker, Mission Control, sign-in and the extension points that attach
score entry, the match-number search and the facility progress cards to the
shared views.
tools/match-control.htmlis a redirect stub to/tools/control-center, so a bookmarked link to it keeps working.match-control-console-spec.mdandawards-podium-tab-spec.mdcall the console Match Control throughout; they are a record of how it was built.
Config resolution¶
Selecting an event resolves three things fresh, every time:
CURRENT_EVENT_KEY/EVENTS_REGISTRYentryCURRENT_TYPE—"dual-meet","standard"or"team", required and explicit, never inferred from the data. Guessing from team-code shape works most of the time and fails silently; an unrecognized/missingtypeshows a visible configuration error instead.DISPLAY— the optional{ divisions, events, clubs }label maps. Absent is fine; the console just shows raw codes (LIWD,PNF) instead of friendly labels.
Everything else the console needs maps onto that: the category set is the
second code segment (DIVISIONS × EVENTS), stage (RR/QF/SF/Bronze/Final)
comes from generic regexes in STAGE_META — not per-event — and the day
label comes straight from the published snapshot's own label field.
Mission Control is event-agnostic. Its dependency surface is just
EVENT_KEY — it touches none of DIVISIONS, EVENTS, CLUBS,
STAGE_META, parseCode, or divisionLabel. That's what makes it safe to
share unmodified across every event type.
The tabs¶
Live Matches, Match Finder, and Standings have no club logos anywhere
(no .score-logo/.live-team-logo/.cs-logo treatment); the console
ignores isLive and always shows live data (it builds its day model with
alwaysLive: true), so an operator can preview a day before it's public;
and labels degrade to raw codes when display config is absent rather
than erroring.
Standings layout is chosen by type. dual-meet gets per-club columns
inside each category with a shared club win-total summary bar and the
cross-club Bronze/Final shown as one block spanning both clubs (see
CROSS_CLUB_STAGE_KEYS); standard gets flat category cards with a
desktop toggle bar and a mobile category-search filter; team gets
group tables, playoff matchup cards and collapsible bracket matchups (see
the team type below).
Unnamed pairs. isEmptyStanding marks a row whose player1 is blank or
still equals its teamCode (the sheet fills names by formula, defaulting to
the code). A twice-to-beat game's code carries its game number
(IXD_F_1_(2)) but its placeholder name doesn't (IXD_F_1), so the name is
also compared with the code minus that suffix. renderStageTables keeps such an RR row when
isUnnamedScheduledPair holds: it is an RR code and appears in
MATCH_BY_CODE, so it's a real pair on the schedule, and pairCell prints
its code. A code with no match, an unused slot in a hand-built workbook with
fixed-size rosters, is skipped. Playoff cards and the Awards podium keep using isEmptyStanding
alone, so they show TBD and never award a code. The same pair of functions
is in every standard and dual-meet event page and both templates.
Round Robin brackets. renderStageTables groups a category's Round
Robin rows by bracket label. With no labels it draws one table; with labels
it draws one mini table per bracket, each under a colored Bracket <n>
label, in .rr-bracket-grid — at most two per row, a third wraps. It
returns { html, hasMultiBracket }; a standard category card with two or
more brackets gets .standings-col--wide on desktop, exactly two columns'
width (740px = 360 × 2 + the 20px gap). Its header carries a desktop-only
.bracket-cols-toggle button; clicking it adds the category to
stackedCategories, which swaps --wide for .standings-col--stacked (a
normal 360px column with the brackets in one grid column). The set lives in
memory, so the choice survives each poll's re-render but not a page reload.
A dual-meet club subsection uses
the same grid but never widens its column. The standard template's
index.html renders standings the same way; the dual-meet template does not
split by bracket.
Desktop standings need a window at least 900px wide and 600px tall
(DESKTOP_STANDINGS_QUERY, (min-width:900px) and (min-height:600px)).
The height half keeps big phones turned sideways, up to about 956px wide but
under about 450px tall, on the mobile layout. isDesktopStandings() reads
the query through matchMedia, and the stylesheet's @media rules use the
same query, with (max-width:899px), (max-height:599px) for the mobile
side; change them together. The public event pages and both templates use
the same query. Desktop standings break out of the page's .wrap to the
full viewport width. Every category is one fixed 360px column
(flex:0 0 360px) in a single row that scrolls horizontally, always
justify-content:flex-start — a centred flex row that overflows cuts off
its first columns in Chromium. Each column is capped at 92vh and scrolls
on its own. These sizes match the public event pages' standings board.
Pair names never wrap or truncate: each table sits in a .br-table-wrap
that scrolls horizontally if a name is wider than the card. A row whose names
aren't in yet (TBD or its code) keeps the two lines a pair's names take, so
it doesn't grow when they arrive. Playoff matchup cards hold the same height
the same way, but there each name keeps to one line, cut with … when too long
(full names on hover). The public event pages do the same in both places,
cutting long names in the round-robin tables too. The desktop
row and every .br-table-wrap are in SCROLLABLE_SELECTOR, so their
scroll positions survive the re-render that every update triggers (a pushed snapshot, or the 10-second poll).
Awards is the second tab — full derivation and export architecture in its own section below.
Mission Control — see Mission Control usage
for what it does; scoped to whichever event is selected. Every call the console
makes goes to the API's /v3 surface (API): sign-in is
POST /v3/sessions, Resync is POST /v3/days/{day}/syncs (or
…/facilities/{facility}/syncs, with { "method": "csv" } in the body for the
CSV fallback) and the go-live switch is PUT /v3/days/{day}/visibility. The
legacy and /v1 routes it called before 3.0.0 stay served, frozen, so a cached
copy keeps working. The go-live states are auto
(live 4 hours before the day's earliest scheduled match time, computed live
from the synced Schedule column — no hour to configure), true (force
live), and false (Live/Standings hidden, scores suppressed on Tournament
Hub only — this console's own tabs stay live regardless, so an operator can
verify a fix before un-hiding). Check connection also reports whether Cloud Run is publishing to live push
(GET /v3/diagnostics/sync's live, which needs the sign-in; a 404 there
reads as "Cloud Run is older than 3.0.0"), and Sync method is the emergency
switch between live push and GitHub only (PUT /v3/settings/live-push, which
writes livePush into config/events.json). The page
itself holds the same live channel block as the public pages, and the
Live updates row in Facility sync status reports whether its own socket is
open. The connection-check line also surfaces the
currently-cached sync config's short SHA (see sync
pipeline § Observability), so an operator can see at a
glance whether a just-committed events.json change has actually taken
effect yet.
Each facility row in the Facility Sync Status list links directly to that
facility's actual Google Sheet (docs.google.com/spreadsheets/d/<sheetId>/edit).
FACILITIES (resolved per day-select from EVENTS_REGISTRY) carries
sheetId alongside name for exactly this. This exposes nothing extra:
sheet IDs are already public in the
same config/events.json fetch the console already makes (see event
registry schema § Sheet IDs are effectively
public). The link is omitted for a facility with no sheetId yet, the
same "not set up" condition the sync pipeline itself skips.
Tab order and landing. The tabs run Mission Control, Awards, Attendance,
Match Finder, Live Matches, Standings, Teams. Teams shows only for a "team"
event whose snapshot carries rosterCsv (syncTeamsTab()). Attendance is hidden unless the event's
attendance is "console" or "desks" (syncAttendanceTab(), called on
every event change, day change and tab reveal). showView(view) is the one place that switches
views (the tab buttons call it), and revealLiveTabsAfterLoad() calls
showView('organizer') once a day loads, so the console opens on Mission
Control.
Match Finder's empty state and number search. With nothing searched,
renderIntro() adds allMatchesHTML(): every non-bye match as a ticket in
match-number order, built with ticketHTML(m, null, false), where a null
team code means a neutral ticket (team 1 on the left, no You badge). A
team event's teamRenderIntro() lists every matchup card after its team
chips through allMatchupsHTML(), sorted by matchupFirstNum() (the
matchup's lowest match number, so #3/#8/#9/#17 sorts as 3) rather than
trusting TEAM_MATCHUPS' insertion order, which follows the CSV's row order.
PickleDrive's event page carries the same two functions.
A refresh redraws the search that was run, not the box's text.
runSearch() records what it shows (shownQuery, and the picked
shownTeam/shownEntry). Every data refresh calls refreshFinder(), which
re-runs that search, or redraws the all-matches list when nothing was
searched, then puts back whatever is half-typed in the box. Running the box's
text instead would swap the list for a short "Several pairs match" message while
someone pauses mid-word, shrinking the page so the pinned search box has nothing
left to scroll against. A new day clears
shownQuery. The event pages work the same way. runSearch() first checks MATCH_NUMBER_SEARCH_RE (42 or #42);
a match number goes to renderMatchByNumber() (the ticket, or the matchup
card with onlyMatch) and is not saved as the remembered search. The event
pages carry the same allMatchesHTML() and neutral ticket, but not the
number search.
How outcomes are shown. Short outcomes go to showToast(kind, text, key),
a stack pinned to the top of the screen (newest first) (#toastStack, aria-live), so
they are seen wherever the button sits: the go-live and sync-method switches
(their current state is already on screen), sign-in and sign-out, and
"pick a day first" / "sign in first". A toast with the same key replaces
the last one; errors stay about nine seconds and can be closed. Results worth
reading go in a box directly under their button, rendered as labelled rows by
showResultRows() and scrolled into view: Check connection
(#orgConnResultBox) and the resyncs (#orgResultBox, one row per facility
from the sync response via resyncResultRows()). Error text from
sage-tools-api embeds the upstream service's raw reply (HTTP 403 {"error":
{...}} from Google, {"message": ...} from GitHub, {"error": ...} from the
live Worker); friendlyApiMessage() turns each HTTP <status> <json> into
words plus the JSON's own message before anything is shown.
Every finished result box gets a close button (addResultClose()); one still
showing its loading message doesn't. clearResultBoxes() hides and empties
every .organizer-result and bumps resultEpoch; selectEvent(),
selectDay() and showView() (only when the view actually changes) call it.
A box records the epoch on its loading message, and a result arriving under
a later epoch (a resync still running when the operator switched day) goes to
showToast() instead, keyed by the box's id, so it is neither lost nor shown
under the wrong day. Toasts are made visible by flushing styles
(void toast.offsetWidth) before adding show, not with
requestAnimationFrame, which never fires while the page isn't painting.
Teams tab¶
Team events only. The day model parses each facility's rosterCsv
(teamCode,player,level,gender) into model.team.roster, and
renderRosters() (createTeams in lib/v1/views/teams.js) draws one collapsible card per team, grouped by
bracket from the standings, players sorted by level. The finder's index adds
roster players to Match Finder, and the team and player results carry a
roster card. The rules (lib/v1/domain/teams.js) and the cards are a port of the event page's
Teams tab (events/pickledrive-anniversary-2026/index.html), whose copy is
frozen with that finished event.
Attendance tab¶
The list is createAttendanceView in lib/v1/views/attendance-view.js, shared
with the desk page (lib/v1/apps/attendance-desk.js); its styles are
lib/v1/css/attendance.css. It reads each
facility's people through GET /v3/days/{day}/facilities/{facility}/people when the tab
opens, when the page comes back into view, on Refresh, and when a nudge arrives (nothing
polls), and marks through PUT /v3/…/people/{personKey}/attendance, both with
the operator token. The tab needs a sign-in: signed out, the view fetches nothing and
shows "Sign in to see attendance." where the list goes. A failed load keeps the list it had
and shows the server's message. In this console it loads every facility of the day, so the counts are
complete.
Around it, the console's own attConsole* section adds the per-facility
counts, Update roster (POST /v3/days/{day}/attendance/reconciliations),
Issue desk link (POST …/desk-links, QR drawn in a canvas from
qrcode-generator 1.4.4 on cdnjs, SRI-pinned, loaded on first click) and
Needs attention. showView() mounts the view on entering the tab and
destroys it on leaving.
The attendance signal. attConsoleMount() also opens createAttendanceSignal
(lib/v1/data/attendance-signal.js), a socket to the Worker's attendance object for the
day, kept in attConsoleSignal. A nudge makes the view re-read that facility
(changed(facility)), and the socket opening or dropping tells it (setLive(open)),
which shows or hides Live updates are off… under the search box. attConsoleDestroy()
closes the signal, and every exit from the tab goes through it: another tab, a day or
event change, a remount. Signing out does not tear the tab down, so the signal stays open and
a nudge's read finds no token, leaving the view on Sign in. The day's liveChannel for
snapshots is separate and untouched. See push. Messages go through showToast and
showResultRows like the rest of the console. With ?fixture= on localhost
everything reads _fixtures/ and makes no API call; ?attfail makes the first
mark fail.
Full design: event attendance.
Score entry¶
An operator enters, corrects or clears one match's score from Match Finder
(PUT /v3/days/{day}/facilities/{facility}/matches/{matchNumber}/score, see sync
pipeline § Score entry writes). The
feature is on for an event whose events.json entry has "scoreEntry":
"console" | "links", and the console only offers it while an operator is signed
in. Usage: Entering a score.
The score dialog¶
The dialog is createScoreDialog in lib/v1/views/score-dialog.js, shared with
the scorer page (lib/v1/apps/scorer.js); its .score-* rules are
lib/v1/css/score-dialog.css. Each page carries the <dialog
id="scoreDialog"> markup, and the dialog finds its parts inside it by id. It uses no global of either
page: everything that differs is passed in through opts.
| Option | What it is |
|---|---|
dialog, apiBase, getToken(), fixture |
the element, Cloud Run's base URL, the bearer token (the operator's here, the scorer's on the scorer page), and the localhost fixture name |
describe(m) |
the title, the sub-title and the two sides (name, code, players or null, and missing, the text for a side with no players) |
readOnlyReason(m) |
a reason to open read-only, or null |
extraWarnings(m) |
the page-specific warnings (an unneeded series game, a missing lineup) |
findMatch(facility, num) |
the page's current copy of a match, for refresh() |
notify, friendlyError, unreachable |
outcomes as toasts, and error text in plain words |
expiredText, resyncHint |
the sentences that differ between an operator and a scorer |
onFixtureSave |
patches the page's own data when a fixture save "succeeds" |
It returns { open, close, refresh, isOpen }.
Which matches are clickable¶
scoreEntryAvailable() reads the registry entry's scoreEntry and the sign-in.
scoreAttrs(m) returns the data-score-*, role and tabindex attributes, or
nothing for a BYE or when entry is not available; scoreHintHTML(m) is the
pencil hint. ticketDecorate(m) packs them into the decorate option the
shared ticket and the team matchup rows take (decorateRow). Only Match Finder
asks for it, so the console's other tabs are read-only: the team views' Match
Finder callers (the team and player results, the all-matchups list and the
match-number search) pass interactive: true and Standings' calls leave it off. Two delegated listeners on the results
container (click, and Enter or space on a focused match) open the dialog, because
Match Finder redraws all its markup on every poll and push, which would drop any
listener bound to a single match. Signing in or out redraws Match Finder, so the
hint appears and disappears with it. Changing the event or the day closes the
dialog.
States¶
One state object lives inside the closure: readonly, enter, review,
saving or conflict. It holds a copy of the match
and the expected values the person was shown (the two team codes and the two
scores). It copies because every poll and push rebuilds the page's match list, and
a dialog that read the live object would change under the operator mid-entry.
expected is what the API's check compares the sheet with. refresh(), called
after every snapshot, compares the page's current match with expected and puts
a note under the boxes; it never touches the inputs and never updates expected,
so saving after the sheet changed gets a 409 and the person chooses between
Keep the sheet's score and Replace with yours (which saves with the
sheet's current values as expected).
Esc and the × button are blocked while saving. A second Enter or space within 400 ms of Review appearing is swallowed in the capture phase, so a fast double Enter from the last box stops on Review. The save request has a two-minute timeout. After five seconds the dialog says the workbook is slow. A response that arrives after the dialog closed is ignored. The 200, 409, 401 and other responses map to toasts and steps as described in the spec's save table.
Fixtures¶
On localhost, ?fixture=<name> simulates a save: after 700 ms it patches the
page's match, redraws and says nothing was sent; the next poll restores the
fixture's scores. Adding &scoreConflict=1 makes the first save of a dialog
answer with a conflict instead (the sheet "now reads" 11 – 9), so the conflict
panel can be exercised. _fixtures/config.json carries "scoreEntry": "links"
for both fixture events.
The Scorer links section¶
Mission Control's Scorer links section (renderScorerLinks()) reads the
registry entry's scoreEntry and is hidden unless it is "console" or
"links". It is called from renderAuthStatus(), from the top of
renderOrganizerStatus() and when the event or day changes, so it follows
sign-in and the selected day. The Accepting / Stopped toggle (a role="switch" button, the same .switch style as
the Use CSV export fallback toggle) calls PUT
/v3/events/{event}/score-entry (body { scoreEntry }) with the operator token and then copies the new
value into the page's own registry entry, which would otherwise stay stale until
a reload. Issue scorer link calls POST /v3/days/{day}/scores/scorer-links and
renderScorerLink draws the result like a desk link: a read-only input, Copy,
Share, Show QR (attConsoleShowQr, which takes a label for the canvas's
aria-label) and the validity line. In a fixture no request is made; the issued
token has a real payload (scope: "score-desk", the day, and to and note when given) so the
scorer page can decode it, and the link points at the local scorer.html.
Who the link is for. Issue scorer link (issueScorerLink) and Issue desk link
(attConsoleIssueDeskLink) run the checks that need no input (a day, a sign-in, and for a
scorer link the scorer page), then call openLinkDialog({ title, notePlaceholder, onIssue }).
It opens one shared <dialog id="linkDialog">, styled with the score dialog's classes, with two
optional text inputs, Issued to (#linkDialogTo) and Note (#linkDialogNote), each
maxlength="40". It clears both every time it opens, so the next link isn't labelled with the
last one's name. On Issue link, linkIssueFields() reads them, trims them, and hands only
the non-empty ones to onIssue (sendScorerLink or attConsoleSendDeskLink), which sends
them as the JSON body of POST /v3/days/{day}/scores/scorer-links or …/attendance/desk-links
and resolves true once the link is shown. The dialog then closes; on false it stays open with
what was typed. A toast would sit under the modal's backdrop, so while the dialog is issuing
(data-busy on it) showToast writes any warning or error into its #linkDialogError instead. Cancel, × and Esc close it, and so
does switching the day or event (closeLinkDialog, beside scoreDialog.close()). The result title
becomes Scorer link for Oct 3 · Ana (Gate A) from the echoed issuedTo and note
(issuedLabel, set with textContent). Fixture links, desk links too, have a real payload, built by
fixtureLinkToken() as UTF-8 JSON in base64url, the way the API writes one. A fixture desk link
works like a fixture scorer link: it points at the local attendance.html with the same
&fixture=, and it lasts to the end of the event's day in Manila, or of today once that day has
passed, so it opens when issued.
The scorer page must exist. scorerPageKnown(eventKey) does a same-origin HEAD request for
/events/<key>/scorer.html (a fixture also accepts scorer-beta.html), remembers the answer
per event for a minute and re-checks after that. Until it is true, renderScorerLinks() disables the
toggle and Issue scorer link and says why, and setScorerLinksMode and issueScorerLink
refuse to run even if the button is forced. A failed check counts as "no page". This is only a
console guard: the API cannot see the site, and a scorer link simply 404s without the page.
The dialog's side labels follow describe(): a side whose name equals its code (a pair) is led by its
players and shows the code as a chip; a side with a real name (a team) keeps the name as its
headline.
Full design of the page the link opens: Scorer page.
Facility progress¶
computeFacilityProgress(matches, isEventDay, nowMin) turns one facility's
matches into done/left counts, a schedule grid (slot length, rows, courts,
blank cells) and an estimated finish. renderFacilityProgress draws a card
per facility on Live Matches (called from renderLiveMatches), and
facilityStatusProgressHTML adds a line to each Mission Control sync row
(called from renderOrganizerStatus). Both go through facilityProgressFor
and facilityFinishParts, so the two can't disagree.
Times are event-day minutes: minutes since midnight at the start of the
day's date, which keep counting past 1440 after midnight. Schedule times
before DAY_ROLLOVER_MIN (6:00 AM) are read as the night after the event
day. The stale test in facilityDataIsStale is the same rule as the sync
row's isWarn; change both or neither. Design and worked examples:
facility progress spec.
Left and In play¶
computeFacilityProgress returns left as every unplayed match, in-play ones
included, and inPlay separately. The estimate (unitsLeft counts an in-play
match as half), the "all matches done" test (left === 0), the stale check
and the server's completedAt all depend on that meaning. The card prints
left - inPlay as Left, so Done + Left + In play equals the total. Change
the printed number in facilityProgressCardHTML, never left.
Actual end¶
A finished facility (left === 0) shows when play actually wrapped, beside
its scheduled end. facilityActualEnd supplies it, preferring the snapshot's
own facilities[].completedAt, which SyncService stamps server-side (see
sync pipeline § Facility completion). That stamp is set
once and carried forward, so it survives a manual resync and reads the same
on every device. eventDayMinutesFromISO converts it into event-day minutes
for facilityFinishParts, which rounds it to 5 minutes and reports the
difference from plannedEnd as "late", "early" or "on schedule". (An
unfinished facility's estimate keeps "behind" and "ahead".)
For a snapshot with no completedAt, the fallback is the
earliest syncedAt this browser has seen while the facility was complete,
kept in localStorage under sage.facilityEnds (keyed
event|day|facility) and dropped when the facility reopens. It is
per-device, so a device that first opens the page after a resync reads that
resync's time instead. That is why the server stamp takes precedence.
The "played", "BYE" and series-final rules here (rowsToMatches,
sideIsBye, seriesGameOf/unneededSeriesGames,
computeFacilityProgress's left) are duplicated in
sage-tools-api/src/sync/domain/facilityCompletion.mjs. If one copy changes and the
other doesn't, the recorded end time disagrees with the card that announces
it.
Installability¶
Installable via its own manifest, tools/control-center.webmanifest, the
same pattern Tournament Calculator established —
scope is the page path (/tools/control-center.html), not the /tools/
directory, so installing this doesn't sweep in scoresheet-generator.html
or the calculator itself. Icons and theme/background colors reuse the
shared site assets and palette, same as the calculator's manifest.
Deliberately no service worker. Unlike the calculator (whose whole justification for offline support is that nothing it shows can go stale), Control Center's entire value is live data — scores, sync status, court assignments — so caching any of it risks showing an operator something stale during a live event, exactly the failure mode the calculator's own spec ruled out for pages like this. The manifest alone is enough for "Add to Home Screen" / an install prompt and a standalone window; it adds no caching and changes no runtime behavior. An installed shortcut still needs a live connection to do anything, identical to a regular tab.
Narrow screens¶
The console gets opened on a phone at a venue, so it holds up at 375px, and at 320px on the smallest common handsets. Three pieces carry that:
- The view-tab row is a horizontal scroller below the breakpoint. Five pills need ~400px in a row, more than a phone has, so they scroll rather than wrap — wrapping costs ~30px of vertical space in a hero that is already tall. An edge fade appears only on the side that still has content past it, and activating a tab scrolls it fully into view, so nothing is ever hidden without a cue.
justify-content is flex-start while scrolling, not center. A centred
flex row that overflows makes its leading items unreachable in every
browser.
The row is measured when it is revealed (after a day loads), not at
script load — while display:none its scrollWidth/clientWidth both
read 0. Anything that reveals those tabs by another path must call
syncViewTabsScroll().
-
The standings club-summary boxes let their identity and stat blocks shrink (
min-width:0) below 520px. They sit in aflex:1box, soflex:0 0 autocontents would have nothing to give and would spill past their own border. -
Mission Control's status rows wrap their detail onto its own line on small screens, rather than truncating the facility name.
Tap targets key off @media (pointer:coarse) rather than a width
breakpoint, so a tablet gets them too — it is wide but still driven by a
thumb.
Theme¶
The console is a tool — it lives in tools/ beside
scoresheet-generator.html and tournament-calculator.html, which are
where the S.A.G.E. palette (navy structure, green accent, off-white paper;
Archivo Black / Barlow Condensed / Inter) originated, and it uses that
palette directly rather than porting a converted theme from the old
per-event pages. Two consequences:
- It does not take on per-event theming. An event may re-skin its own Tournament Hub, but the console looks identical regardless of which event is selected — it's one tool pointed at different data, and an operator switching events shouldn't see the furniture move.
- Contrast rules worth knowing if touching the CSS: green is a fill
colour, not a text colour (
--greenon white is ~2.3:1, fails AA at any size — use it as a background with navy text on top, or on a navy panel). The masthead is a navy bezel on paper; controls sitting on it need their own light-on-navy overrides rather than reusing the paper-ground base rules.
Awards tab¶
A fifth tab showing each category's podium and exporting it as
SAGE-branded PNGs. Entirely read-only and derived — it writes nothing,
needs no auth, and adds no new sync surface or sage-tools-api dependency.
Everything it shows comes from MATCHES (the day's parsed match rows) and
STANDINGS, already loaded by the console for the other tabs.
Podium derivation¶
buildPodiums() is the single pure function everything else consumes:
(MATCHES, STANDINGS) -> [{ category, source, gold, silver, bronze,
bronzeWalkover, warning }], ordered to match Standings' own category order.
For each category:
- Find the decisive Final. A Final played as a series (twice-to-beat,
best-of-3 — see below) is decided by the game where a seat reaches its
wins. Otherwise, among all matches tagged
Ffor that category, the decisive one is the resolved instance with the highest instance number, where resolved means played, or decided by a bye walkover. A category with noFmatch at all (pure round robin) falls back to its top-3 standings rows instead, taggedsource: 'standings'. - Find the decisive Bronze, the same way. A category with no Bronze match at all can still have its bronze decided in the standings: see below.
- Gold/silver = winner/loser of the decisive Final. Bronze = winner of the decisive Bronze, or the standings walkover's pair.
- Anything that can't be resolved cleanly — a tied score, or both sides of
a match reading
BYE— produces awarningnaming the match number and suppresses the whole category's podium rather than guessing. Nothing here ever fabricates a winner.
Series finals: twice-to-beat and best-of-3¶
A Final played as a series of games carries the seat and the game in each
team code, <prefix>_F_<seat>_(<game>) (IXD_F_1_(2)); seriesGameOf()
reads them. Two games is twice-to-beat: seat 1, the round robin's #1,
needs one win and seat 2 needs two. Three is best-of-3: two wins each.
walkSeries() counts wins game by game; the game where a seat reaches its
wins decides the series, and a tied game counts for neither seat and raises
the usual tie warning. So a challenger who wins twice-to-beat's game 1 has
forced game 2, not won the Final: gold stays Pending until game 2.
Every game after the decider is never played. unneededSeriesGames()
returns them, and computeFacilityProgress leaves them out of the total, so
a twice-to-beat Final whose #1 won game 1 doesn't hold "matches left" at 1.
The public hub and the schedule board carry the same rule and grey such a
game out (.not-needed, with a "Not needed" tooltip and no label): its
finals matchup, its Match Finder ticket, which is never flagged Next Up,
and its schedule board cell.
Byes, and why they're detected the way they are¶
A twice-to-beat bracket often has no Bronze match to actually play — third
place is already determined by the bracket structure. Rather than special-
case that in code, it's encoded in the data: the operator types BYE into
the opponent's team code or either player-name cell, making the real side a
winner by walkover. matchByeSide(m) checks all three cells (both team
codes, both pairs of player names) — deliberately, since the sheet
autofills these by formula, so whichever cell the operator overrides has to
be the one that's checked. A bye match is decisive without scores —
rowsToMatches() sets played from the score columns, so a bye match has
played === false, and the podium derivation explicitly does not gate on
played when a bye side is present.
A bye-decided bronze shows its medalist like any other, with no score and no label (the Awards tab and its images never write "walkover"), and the bye side is never rendered as a person anywhere.
A bronze decided in the standings alone. A workbook can also leave the
Bronze off SCHEDULE entirely, so no Bronze match reaches CSV, and set
it up only in STANDINGSCSV: one bronze slot holds the round robin's #3,
the other reads BYE. standingsBronzeWalkover() recognises that shape
(exactly one real B row and one BYE row). When the category has no
Bronze match at all, Awards gives bronze to that pair once its names are
in, and Standings drops the Bronze
block the same as for a bye-decided match.
Byes must not surface where a match looks playable¶
A bye is never a real match, so it's filtered out of every surface that presents match data as something to watch or play, not just the podium:
| Surface | What's filtered |
|---|---|
| Standings | Any row whose team code/player1 is BYE; if a category's decisive Bronze was bye-decided, or its bronze slots are a pair and a BYE with no Bronze match, the whole Bronze block is dropped (not just that row) — a one-sided "Bronze Battle" showing one team with no opponent is worse than showing nothing |
| Live Matches | Bye matches excluded from court grouping entirely |
| Match Finder | A team's own bye match never appears in their schedule, and can never be flagged "Next Up" |
| Team index / autocomplete | BYE is never indexed as a searchable name |
The team type¶
"team" is for events of named teams meeting in four-match matchups (spec:
sage-docs/docs/specs/.../pickledrive-club-anniversary-team-tournament-spec.md). It is additive: every
change to a shared function is either inside a CURRENT_TYPE === 'team'
branch or an extra parsed field nothing else reads, so dual-meet and
standard are unaffected by it. The team logic lives in one bannered
block (// ---- team type (...) ----, just below pairCell), ported from the
event page's index.html so the two agree on every rule.
Data comes only from the two published CSVs. rowsToMatches reads a
matchUp column and rowsToStandings reads teamName, totalPoints and
totalOpponentPoints when present; matches are grouped into matchups by
matchUp alone. teamMatchupResult decides a matchup on total points (equal
points is a tie), or, with its scoring parameter set to "games" (the event's
teamScoring), on matches won and then total points, and sideLabel / teamNameOf / baseTeamOf turn a code
such as SF-A_2 into a team name and letter, reading a playoff slot as its
team once an operator has typed the letter into the workbook. STAGES
maps a playoff side's prefix to its label and order: QF Quarterfinal, SF
Semifinal, Br Bronze, Fi Final. A team is marked Advances when its
letter fills any playoff slot, which with quarterfinals means the eight
QF-* qualifiers.
Functions that branch on team:
| Function | Team behaviour |
|---|---|
selectEvent |
Accepts type: "team"; resets the team state |
parseCode |
Returns { club: null, category: null, rest } so no shared caller breaks |
renderStandings |
Calls renderTeamStandings() before any category code runs; the category toggle bar and filter stay hidden. Each bracket table is ordered by teamRankBracket: points scored, quotient, head-to-head points, pair wins |
liveTableRowsHTML |
teamLiveTableRowsHTML: both team names, stage and pair label, running matchup score |
rebuildTeamIndex, resolveTeam, runSearch, renderAutocomplete, selectAcItem, renderIntro |
The team-and-player index, its search and the intro's team list; a saved search is team:<letter> or player:<name>, never a playoff code |
renderAwards |
buildTeamPodium() instead of buildPodiums() |
categoryLabel |
'__team__' reads Team Championship |
medalRowHTML, awardsCardHTML |
Show the team name, with the roster under it |
drawPodiumCard, renderWholeTournamentCard |
Draw the team name with the roster beneath (wrapCanvasList, breaking only between names); the podium card's fixed-height row uses fitRosterLines (24 → 20 → 17px, then "+N more") |
buildTeamPodium() returns one podium in the usual shape. Gold is the Final
matchup's winner, silver its loser and bronze the Bronze matchup's winner; each
is null (Pending) until that matchup is final. A tied Final or Bronze sets a
warning naming the matchup's lowest match number and leaves all three
placings null. computeOverallChampion still returns null outside a dual
meet.
On localhost, ?fixture=<name> loads /_fixtures/config.json as the registry
and /_fixtures/<eventKey>/<name>.json as the snapshot, for testing without
publishing anything; the hostname check keeps it inert on the live site. The
console keeps the house theme — only the event pages take a per-event palette.
Overall Champion (dual-meet only)¶
Deliberately the same metric as Standings' own club win-total summary — total wins across Round Robin rows only, per club — not a podium-derived count (e.g. gold medals), so the two tabs can never disagree about who's ahead. A tie is shown explicitly as "Tied," never resolved by an invented tiebreak.
Image export — Canvas 2D, no library¶
tools/control-center.html has zero JS dependencies (Google Fonts is the
only external resource), so the export draws directly with the Canvas 2D
API rather than pulling in html2canvas or a similar library. Cost: the
card design lives in drawing code, not CSS.
Per-category card: fixed 1080×1350 logical canvas, rendered at 2× (with a fallback to 1× if that would exceed a safe canvas-area budget — iOS Safari caps total canvas area near 16.7M pixels). Each medal row fills its whole box in the medal tint once decided (dark navy text — checked for contrast: ~7.3:1 on gold, ~8.9:1 on silver, ~4.7:1 on bronze); an undecided placing stays on the plain navy panel, so the row's own color signals decided-vs-not.
Whole-tournament sheet: every category in a grid (column count picked
by category count, to keep the image from becoming an ever-taller single
column) rather than one row per category. Nothing is ever truncated —
category names, player names, and club labels all word-wrap; wrapCanvasText
force-breaks at the character level for the rare single "word" (a long
hyphenated surname, say) that's wider than its column on its own, so no
line can ever overflow its box.
The measure-then-draw pattern. Both the per-card and whole-tournament
drawing functions take a draw boolean and run through the exact same
layout logic either way — called once with draw: false (nothing painted,
just wrapping/line-counting to get a height), then again with draw: true
at the real position once the canvas is correctly sized. This is what
makes it possible for content to be genuinely dynamic-height (wrap to
however many lines it needs) without measuring and drawing ever disagreeing
about where something lands — they're driven by identical code, not two
hand-synced implementations.
Fonts are explicitly document.fonts.load()-ed and awaited before the
first stroke — ctx.font silently falls back to a system font if the
family hasn't been requested by some DOM node yet, with no error to
catch, and this is the single most likely way a cold-load export comes out
wrong while the on-screen tab looks fine.
Features: Control Center · Awards tab usage
Specs: match-control-console-spec.md and awards-podium-tab-spec.md (full build history, acceptance checklists — both call the console Match Control).