Skip to content

Schedule board

A standalone page per event (events/<event-key>/schedule.html, public URL /events/<event-key>/schedule) — a wall display for the venue: courts as columns, time slots as rows, one face-off card per match, color-coded by category, live matches highlighted and results filling in as they're played. Deliberately unlisted — nothing on the public site links to it; Mission Control is its only entry point (see Control Center).

Why a separate page, not a tab

  • Opposite audience needs. The public index.html is a phone lookup tool for a player finding their own match — hero, search, day picker, tab bar. The board is a passive wall display where every one of those costs a match row; its masthead is 48px.
  • Theme collision. This page uses the SAGE tools theme (scoresheet-generator.html / tournament-calculator.html's palette), not the event pages' dark theme — both define --paper, --ink, .cell, .legend, .dot, so sharing a document risks cross-contaminating them.
  • Weight. The event index.html is already substantial; the board is a fraction of that standalone. Folding it in would tax every phone visitor for a view they'll never open.

It needs almost none of the shared config surface (the division and event labels, clubs, search, standings, or bracket logic) — just the event/day key, the data-repo location, and a category color map. The page is a shell of the site engine: its markup, its palette and a settings script (EVENT_KEY, DAY_KEY, CAT_META passed as categoryColors, and a dual meet's CLUB_ORDER as clubOrder) that calls mountScheduleBoard in lib/v1/apps/schedule-board.js; its styles are lib/v1/css/schedule-board.css.

Match cell

Symmetric: team 1 left, score centered, team 2 right, mirrored. Player names stack two lines, truncating with an ellipsis. Each name line keeps its height while empty (.fo-name's min-height), so a cell whose names aren't in yet is already the height it will be once they are, on a phone too, where the cell's own min-height is dropped. Club is read per side from the team-code prefix, never assumed by position — semifinals in a dual-meet bracket are intra-club (e.g. PNF_LIWD_SF_1 vs. PNF_LIWD_SF_2), so a cell can legitimately be the same club on both sides; hardcoding "club A always left" would be wrong.

A stage pill beside the category chip reads the ladder position off the same roundKeyword() classification the rest of the site uses — lifted verbatim, not reimplemented, so this page classifies a code exactly as Standings and Live Matches do. Pool play renders as a quiet outline RR (it's the large majority of cells); playoff rounds (R16/QF/SF/ BRONZE/FINAL) invert to solid navy so they're findable at a glance across a wall of cells.

Three states: upcoming (no scores, full opacity, VS centered), live (green ring + glow, ● COURT n pill, driven by the court column being non-empty), completed (dimmed to 84% opacity, shows the score — 84% specifically, since a lower value pushed the darkest category's text under the 4.5:1 AA contrast floor).

Court filter — state lives in the URL

?courts=1-5 / ?courts=1,2,7-9. This is the point: a two-screen venue setup bookmarks one URL per screen (?courts=1-5 on screen A, ?courts=6-9 on screen B), and each boots straight into its range after a refresh or power cycle with nobody touching the machine. Clicking a court pill rewrites the URL live (history.replaceState). The legend follows the filter (it keys what's visible). The match count doesn't: it covers the whole board on every screen, so the same label doesn't mean different things on two split screens. Only the scope text changes.

Venue filter — ?venue=

On a day with more than one venue, a Venue row (All + one button per venue) sits before the court filter. The venue list comes from the snapshot's facilities[].name, so there's nothing to configure. With one venue the row is hidden. ?venue=PCPH%20Annex narrows the board to that facility's matches. It is bookmarkable like ?courts= and composes with it (?venue=PCPH%20Annex&courts=6-7). A name the snapshot doesn't have shows every venue.

Court numbers run on across a day's venues (Main 1–4, Annex 5–9), so the grid is still sized from every venue's highest CourtAssignment. A venue view shows the span of courts its own matches use (5–9), and the court filter offers only those. A match with no usable court falls into one of the venue's own lanes. Switching venue clears the court selection, since one venue's court numbers mean nothing at another. It rebuilds from the last fetched snapshot, with no new request. The sub line, the print header and the match count all name and count the chosen venue. A venue with nothing published yet shows a message instead of an empty grid.

A venue view also sidesteps a limit of the all-venues board. Slots are ordered by first appearance in matchNumber order. Within one venue, numbers rise with time. Across venues numbered in separate ranges (1001…, 2001…) they don't, so the all-venues board takes its slot order from the first-numbered venue and adds any time only a later venue uses at the bottom.

A collapsible header (chevron, state also carried in the URL as ?compact=1, composable with ?courts= and ?venue=) hides operator chrome — the venue and court filters and the PDF button — while keeping the color legend, since a viewer still needs that to read the board at all.

Category colors come from the workbook, not invented

Team events colour by bracket, not category. A team event's board keys its colour off the match's bracket (Bracket 1, Bracket 2, Bracket 3) or, for any playoff match, a single Playoffs colour, chosen from the event's own artwork because the team workbook's SCHEDULE tab is uncoloured. Each card also names both teams above their players and carries the pair label; a side whose lineup isn't set (its first player cell is empty or holds the side's own code) reads Lineup TBD. The page sets type: 'team', and the board reads the event's display.pairs from events.json for the pair chip (MD, XD 1), and its display.teamColors for each team's mark and the card's tint: a board row carries each side's group-stage team (base1/base2), the board looks up its mark with teamMarkOf (views/team-mark.js), and a card with a mark takes .team-tint, a gradient from the left team's colour to the right's; its left edge and chip keep the bracket's colour. The colour key is CAT_META with one entry per bracket, keyed G<n>, and PO for every playoff; a bracket with no entry reads BR <n> in grey. A team cell keeps its chips on one line (.cell.team). The rest of this section describes category-coloured events.

Read directly off the color-coded SCHEDULE tab of the source spreadsheet so the wall display and the workbook's own printed schedule agree. These are not exportable — cell fills are formatting, absent from both the CSV and gviz exports, and Sheets paints its own grid to a <canvas> with nothing recoverable from the DOM — so they're recovered by sampling rendered pixels and cross-checked, and must be re-read by hand if the sheet is ever recolored; nothing detects that drift. Applied as a 40% tint over white (the measured contrast ceiling for navy body text on the darkest category) with the solid hue as a left rule; chip text color is derived per-category from luminance rather than hardcoded, since the palette spans light berry to near-black berry.

A PDF button builds print-only pages and calls window.print() — the browser's own "Save as PDF" covers the PDF ask, and the same output prints on paper. Deliberately not a canvas render (unlike bracket-generator.html's export): re-implementing every face-off cell in Canvas 2D and hand-chunking pages would be a second layout implementation that drifts from the CSS the moment either changes, whereas printing gets page breaking, paper size, and DPI from the browser for free, off the same grid-rendering function the live board already uses.

Both axes (courts, time slots) can overflow a sheet independently, so pagination chunks both and takes the cross product — courts as the outer loop, so one court group's whole day lands on consecutive sheets, chunked balanced (9 courts at 4/page → 3+3+3, never 4+4+1). print-color-adjust: exact is required or the category tints — the whole point of the board — print blank; the "completed" dimming is forced back to full opacity on paper, since ink is cheap and a dimmed printed row just reads muddy.

Data & live polling

Reads the same published snapshot the event pages already fetch. Court placement is defensive: a valid, parseable CourtAssignment places directly; anything blank, unparseable, or a duplicate falls to the first free lane; a genuinely full slot surfaces rather than drops a match — a +n badge on the time cell lists the overflow match numbers in a tooltip. Row order must never be trusted as an implicit court assignment. It can appear to work — row order often does line up with courts — and that is exactly what makes it dangerous: an undocumented contract that breaks silently the first time someone reorders a row.

Receives pushed snapshots over the same live channel as the event pages and falls back to the same poll interval and cache-busting convention as the rest of the site (see sync pipeline); a failed poll leaves the last good board on screen rather than showing an error — a wall display showing stale data beats one showing an error message. Re-rendering preserves the current court filter and scroll position.


Features: Schedule board · launching it from Mission Control Spec: schedule-screen-spec.md (full layout spec, pagination math, acceptance checklist).