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=(optionalleague,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_listeneris 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
scheduledand 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/AdminDayRangekit (presets: Today, Tomorrow, Yesterday, Custom). Custom supports one day or an inclusive from/to range. Changing the day reloads viaGET /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 details → Match 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:
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 optionaldist(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.