Skip to content

Matches

The page at /admin/matches lists matches for a local calendar day or date range. Toggle Listener dispatch on each card; edit in-game and Match question overrides on match details (/admin/matches/{id}).

Data source

The admin page is a thin HTML shell. Alpine.js loads:

  • Match rows via GET /api/matches?scheduled_from=&scheduled_to= (optional league, status); admin converts browser-local calendar days to those UTC bounds
  • League names via GET /api/leagues (for the league filter; edit question defaults on League Defaults)

Match overrides live on the match; defaults live on the league.

Each card headline uses display_title (full market + team name). The subtitle uses display_label (team aliases) plus the local scheduled time. Pickers elsewhere keep using aliases via AdminMatches.optionLabel (display_label · scheduled · TEST · shortcode; TEST only on is_test rows).

Each card also shows a short form (first 8 characters) of the internal match id (Match ID / Match.id) and Sportradar match id (sr_id) when present; hover shows the full UUID, and the copy control copies the full id. Match status uses shared AdminMatchStatus labels (e.g. In progress, Closed) with color-coded read-only badges (test and live). Cards also show the most recent listener state (same derivation as Match Listeners). Each listener badge (including No listener) links to Match Listeners with that match deep-linked (match_id, local calendar day, league):

  • Listener running (fresh heartbeat)
  • Listener stale (open row, heartbeat older than ~3 minutes)
  • Listener abandoned (exit 14)
  • Listener stopped (exit 15)
  • Listener ended (other exit codes)
  • No listener
  • Dispatch off when auto_dispatch_listener is false

Listener dispatch is editable on each card (autosave). In-game question overrides are summarized as read-only properties under a collapsed Match settings section pinned to the bottom of the card — edit them on match details. Match settings starts collapsed by default. The summary shows dispatch off when applicable (no overrides / league-defaults label).

On every match card, the Match settings header includes an Actions menu (Start listener, Stop listener). Delete test match appears only for is_test rows on non-main. Start listener is disabled while the badge is Running (stop first) and, for test matches, is env-gated like Match Listeners / clone-and-listen. When enabled it enqueues start_match_listener with force=true. Stop listener revokes/closes the run and sets auto_dispatch_listener=False so Beat will not re-enqueue (hard-fail Abandoned leaves dispatch on). Click the match title to open /admin/matches/{internal_match_id} — an ops page (two columns):

  • Left: identity (including Cloned from and Created by for test clones), related league/season/teams, Match settings (same editable Listener dispatch, In-game question overrides, and Match question overrides as the board — dispatch always editable; overrides only while scheduled and no listener has started), Match sponsor (editable under the same gate; saved with Save overrides), collapsible read-only Question sponsorships (count when collapsed; edit on Brand Sponsorships), Current in-game question settings (what the match uses for in-game questions now: override or league default — past play may differ; see At play on Issuance Explorer; Question Inheritance for live league / group / definition layers — not this match’s overrides), Configured for next start (only settings saved on this match for the next listener start — not un-overridden worker defaults; past runs show what they used under Startup params), and listener lifecycle rows. Current in-game question settings and listener runs are collapsible; each run is collapsible and shows Startup params for the resolved config that run actually used, plus Download log. Download events is on Identity (match-level archive). Field-help ? modals apply where catalog entries exist. Created by is the admin who cloned the test match (blank for live rows and older clones).
  • Right: in-game question summary metrics (issued/resolved/open, answers, correct/incorrect/pending, points, Followers, Feedback) and a primary Issuance Explorer link for this match (league, day, match_id — not a specific issuance). Per-issuance inspect — selection, At play, pinned definition snapshot, resolution modal, and the Match questions list — lives on Issuance Explorer. Followers links to Users filtered by that match (/admin/users?followed_match_id=). Feedback is the user-feedback count for the match and links to User Feedback (/admin/user-feedback?match_id=).

Audit layers on this page:

Surface Meaning
Current in-game question settings Live match/league in-game effective values now
Issuance Explorer At play / pinned snapshot Frozen at in-game issue time (not this page)
Listener run Startup params What that run actually used

Data loads via GET /api/matches/{internal_match_id} and GET /api/matches/{internal_match_id}/question-issuances (Bearer required; in-game history — admins see open + resolved issuances, players/mobile see resolved only). Match questions are on the match detail payload (match_question_issuances), not this endpoint. Delete test match confirms then calls DELETE /api/matches/{id}: stops any running listener, then deletes the match. Postgres ON DELETE cascades owned rows (issuances, answers, sponsorships, listener records) and nulls clone backrefs — see Data model. Live (non-test) matches cannot be deleted (400).

Toggling listener dispatch or push notifications autosaves via PATCH /api/matches/{id} with { "auto_dispatch_listener": true|false } or { "push_notifications_enabled": true|false }. Dispatch off skips Beat / dispatch_upcoming_match_listeners (manual Start unchanged). Push off skips push fan-out for the match (match_push_disabled). Both stay editable mid-match (even when question overrides are read-only); changes apply to subsequent sends only.

This is Database configuration. Saving updates the selected Match row.

Filters

Filters live in a left sidebar. Use them to narrow the match cards:

  • Scheduled day — shared admin_day_range / AdminDayRange kit (presets: Today, Tomorrow, Yesterday, Custom). Custom supports one day or an inclusive from/to range. Changing the day reloads via GET /api/matches?scheduled_from=&scheduled_to=.
  • Search — team names, aliases, internal match id (Match.id), or Sportradar match id (sr_id)
  • League, Status, Listener — dropdowns built from loaded data
  • Test matches — Any / Test only / Live only (Match.is_test)
  • Overrides only — matches with any non-null override field
  • Match sponsor — Any / None / a specific brand (Match.sponsor_brand)
  • Question sponsorships — freeform comparison on total sponsored count (sponsored_question_count), e.g. >= 1, =0, <3

Each card shows the match sponsor as a header badge (when set) and Question sponsorships count in the meta block (links to Brand Sponsorships with that match deep-linked). On match details, Listener state uses the same Match Listeners deep-link. Client-side filters (search through overrides / sponsor / slots) hide non-matching cards without a full reload; day-range changes fetch a new list from the API.

To clone a closed past match (and optionally set question overrides, match sponsor, and question sponsorships at clone time), use Clone Past Match. On match details, a Clone match button (upper right) shows when the env gate is on. It is enabled only for Clone Past Match picker eligibility — status closed, not a test match, and listener_events_archived — otherwise disabled with a hover reason. When enabled it opens Clone Past Match with league, local calendar day (browser TZ via AdminDate), and match_id pre-selected.

Question override fields are edited on match detailsMatch settings (same catalog as Clone Past Match). The list card only summarizes them.

Overrides are editable only while the match is still scheduled and no listener has started. After a listener row exists, or the match moves to in progress / closed / delayed / etc., fields are read-only (API PATCH /api/matches/{id} returns 400).

Overrides

Blank fields inherit the league default.

Match field League fallback Meaning
override_question_max_count default_question_max_count Maximum in-game questions issued for the match
override_question_pivotal_max_count default_question_pivotal_max_count Maximum pivotal-moment issuances for the match (league default 4). Independent of max questions and of per-period pivotal caps; ignore flags do not bypass.
override_question_min_interval default_question_min_interval Match-wide floor (seconds) after a group resolution. Required wait is max(this value, last issuance’s issuance_min_interval). League default is 0 so definition intervals control pacing unless raised. Measured per listener_timing_source: ts uses stream event timestamps; realtime uses real-time. Set timing on listener start settings (Clone defaults to ts).
override_question_period_distribution default_question_period_distribution Per-period question caps (current event's period only). unlisted: block treats omitted keys as 0
override_question_points default_question_points Points for a correct answer unless a definition override wins. Absolute number or *N multiplier of the league default (e.g. *2).
override_question_definition_ids default_question_definition_ids Folder tree of definition ids. null inherits the league allowlist (future League Defaults edits apply; the tree shows the effective league selection). A non-null list is a pinned snapshot for this match. Override for this match copies the current league ids. While pinned, All writes currently active ids (later activations stay off); None is []; Use league default returns to null. Folder checkboxes toggle descendant ids. Persistence is still a flat UUID list (still subject to is_active, weight, triggers, and group gates). Does not cap issuance count — pair with max questions when you want a single issuance.
override_match_question_max_count default_match_question_max_count Cap on Match questions (pre-game). Independent of in-game max. Lowering later does not retract issued questions.
override_match_question_definition_ids default_match_question_definition_ids Match question definition folder tree. Same inherit / pinned override chrome as in-game.
override_match_question_resolution_grace_seconds default_match_question_resolution_grace_seconds Seconds after first terminal observation to keep retrying official Match question resolution before stale/award-all. Postpone/cancel/unnecessary skip this wait and stamp those statuses as the close reason.
override_match_question_earliest_issue_seconds default_match_question_earliest_issue_seconds Seconds before scheduled start that Match questions may first be issued (league default 900). 0 issues as soon as the match exists. Raising later does not retract questions already issued. Not issued after lock or once in-game questions exist.

How those questions appear, lock, and score: Match question issuance & resolution.

The period distribution field accepts JSON:

[]
{}
{"1": 3}
{"1": [3, 1], "2": 2}
{"1:T": [1, 1], "1:B": 1, "5:T": [2, 0]}
{"dist": "even"}
{"dist": "even", "5": [5, 1], "8": 2}
{"dist": "even", "5:T": [3, 1]}
{"unlisted": "block", "1": [4, 1], "2": [4, 1]}

Keys are period labels; values are a bare normal cap (pivotal 0) or [normal, pivotal]. Reserved key dist is a strategy discriminator — today only "even"; other strategies may be added later. Reserved unlisted is allow (default; omitted keys uncapped) or block (omitted keys treated as 0). [] cannot carry unlisted. For NHL/NFL, keys are period numbers (from payload.event.period). For MLB, keys may be half-innings ("1:T", "1:B") or coarse inning numbers ("1""9"). Half labels fall back to a parent inning key when the map only has "1". The listener applies the cap only to the period/inning of the candidate event — filling period/inning 1 does not block issuance in 2. null / omitted override inherits the league value. An empty object ({}) means Issue ASAP — no per-period caps and no auto-fill. An empty array ([]) or {"dist": "even"} opts registered leagues (MLB today) into an even auto-fill of remaining max questions as [n, 1] pairs across regulation half-innings at listener start (leftover slots assigned at random; overrides replace keys). Leave the field blank to inherit the league value.

Period caps and max questions are independent: the JSON values do not need to sum to max count, and unused period slots are not redistributed. Periods missing from an explicit map are uncapped unless unlisted: block — the period gate is skipped there under the default, so remaining questions keep issuing until max count (and other eligibility gates). A value of 0 for a period is allowed and blocks all non-pivotal issuance in that period (different from omitting the key). When a period’s pivotal cap is full, only is_pivotal_moment definitions are blocked.

Example — max 20, distribution {"1": 2, "2": 3}:

Period 1 full (2 issued)  → no more questions while still in period 1
Period 2 full (3 issued)  → no more questions while still in period 2
Total so far: 5
Period 3 (not in map)     → uncapped; can issue the remaining 15 up to max 20
If the game never reaches period 3 after 1–2 are full → stops at 5

Example{"1": 0, "2": 3}: no questions while in period 1; period 2 can still issue up to 3. Omitting "1" would leave period 1 uncapped instead.

See Period distribution vs max count and Auto even period distribution for full details (including pivotal caps, dist, empty distribution, zero caps, MLB half-innings, and start-time even fill).

The browser validates JSON before submission and marks edited cards as having unsaved changes. Leaving the page with dirty edits prompts for confirmation. The server repeats validation and returns HTTP 400 for malformed values.

Save behavior

Overrides save via:

PATCH /api/matches/{match_id}

match_id is the internal match id (Match.id), not the Sportradar match id (Match.sr_id). The server rejects override edits (400) when a listener has started or the match is no longer scheduled. Null fields clear overrides. On success the card updates in place and a toast confirms the save.

Test-match status uses the same PATCH with a body like { "status": "closed" }. Only is_test rows may change status; live matches get 400. Allowed values: scheduled, inprogress, delayed, postponed, closed. Status can change even when overrides are read-only (e.g. after a listener started). Setting closed (or leaving the upcoming window) keeps dispatch from picking the match up again after a Stop.

Listener dispatch uses the same PATCH with { "auto_dispatch_listener": false } (or true). Allowed on any match, including when overrides are read-only. Explicit null is rejected (400).

Push notifications uses the same PATCH with { "push_notifications_enabled": false } (or true). Same editability rules as listener dispatch (including mid-match). Test clones default off at create; live matches default on.

Definition-level override_question_points takes precedence over the match override. Both levels accept an absolute value or a *N multiplier of the next-lower default (match × league; definition × effective match/league points). See Question definitions.

Code map

Concern Location
REST matches list / get / patch / delete / issuance history GET/PATCH/DELETE /api/matches, GET /api/matches/{internal_match_id}, GET /api/matches/{internal_match_id}/question-issuances (auth; resolved-only for non-admins), src/api/routes/matches.py
REST leagues (defaults) GET/PATCH /api/leagues, src/api/routes/leagues.py
Match list/update/detail ops + schemas src/lib/matches/
Shared league fetch + schemas src/lib/leagues/
Admin HTML shell src/api/admin/routes/matches.py
Templates src/api/admin/templates/matches/list.jinja2, detail.jinja2
Board / detail (Alpine) src/api/admin/static/js/matches_app.js, matches_detail_app.js
Card / detail CSS src/api/admin/static/css/matches.css
Tests src/api/tests/admin/test_matches.py, src/api/tests/test_matches_api.py, src/api/tests/test_leagues_api.py

Common failures

  • No matches: confirm local day/range and that matches exist for that window.
  • Invalid JSON: period distribution must be a JSON object / [] with string period keys, integer or [normal, pivotal] values, and optional dist (today only "even").
  • Match not found: the internal match id (Match.id) no longer exists.
  • Unexpected effective points: check definition override first, then match override, then league default.