Replaces PreMatch/Live placeholder pages with a shared EventListShell
(filter chips, date range, sortable virtualized-friendly table, debounced
search, live auto-refresh with odds-movement indicators) and adds a new
/events/{eventCode} detail page (asymmetric header lockup, dynamic
Match/Period tabs, Plotly.Blazor odds-over-time chart with accessible
data-table fallback, snapshot history, Excel export modal).
New primitives matching Phase 5's editorial-quant system:
- SportIcon: inline SVGs per sport (basketball=6, football=11,
tennis=22723, hockey=43658, generic fallback)
- OddsCell: tabular mono with ▲/▼/— delta + flash on change
(prefers-reduced-motion honored)
- OddsTimeline: Plotly.Blazor wrapper with theme-aware colors and
<details>/<summary> data-table screen-reader fallback
- ExportDialog: From/To pickers + ExportKind radio + Esc/Enter
keyboard, surfaces use-case errors inline
- EventListShell: shared section shell for PreMatch/Live cadence
State + service split keeps the RCL host-agnostic:
- IEventBrowsingService / EventBrowsingService — wraps repos, returns
view-model records (EventListItem, EventDetail, EventScopeBoard,
BetRow, OddsTimelinePoint, SnapshotHistoryEntry); pages never see
EF or domain entities directly.
- EventBrowsingState — singleton (per-circuit in BlazorWebView) holding
immutable PageFilter records for PreMatch and Live.
Plotly.Blazor 5.4.1 added (latest .NET 8 line; 7.x has breaking changes).
+59 RU/EN localization keys following the Phase 5 dot-segmented convention.
Tests: +26 bUnit tests (PreMatch/Live/Detail pages, OddsCell/SportIcon/
ExportDialog components, EventBrowsingState). Total 228/228 passing
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline 202).
Build clean (0/0).
PLAN.md: P2/P3/P5 top-level checkboxes ticked; P6 row marked Done.
13 KiB
Phase 6: Event Browsing UI
Status: ✅ Done Parent plan: PLAN.md Domain: frontend Implementer: Opus + frontend-design skill Depends on: Phase 4 (use cases) + Phase 5 (UI shell)
Objective
Build the user-facing browsing experience: pre-match list, live list (auto-refreshing), event-detail view with odds-over-time chart, plus an Excel export trigger. Visual quality must match the design system established in Phase 5 — distinctive, accessible, information-dense without being cluttered.
Tasks
- Create
Marathon.UI/Pages/PreMatch.razor(replaced placeholder):- Filtered list of upcoming
Events viaIEventBrowsingService - Filters: sport multi-select chips, country multi-select chips, date-range, free-text search (debounced 300 ms)
- Sort: scheduled time / country / league (header click toggles asc/desc)
- Each row shows sport icon, time, country, league, match-up, compact
Win-1 / Draw / Win-2
OddsCellpreviews - Click or Enter/Space on a row → navigate to
/events/{eventId}
- Filtered list of upcoming
- Create
Marathon.UI/Pages/Live.razor(replaced placeholder):- Same shell (
Pages/Shared/EventListShell.razor) as PreMatch but data source is live snapshots - Auto-refresh every
Scraping:PollingIntervalSeconds, read live viaIOptionsMonitor<ScrapingSettingsForm>; pulse badge in toolbar surfaces the active cadence - Visual indicator when odds change since last refresh (▲ amber rising, ▼ red falling, em-dash unchanged + flash background)
- Same shell (
- Create
Marathon.UI/Pages/Events/Detail.razor:- Event header: sport kicker, sides 1 & 2 lockup, scheduled time + MSK, Win-1 / Draw / Win-2 odds cluster, Export button
- Tabs: "Match" + dynamic "Period 1..N" generated from snapshot data
- Per scope: Type / Side / Threshold / Rate table for all bets
- Charts panel:
OddsTimelinewraps Plotly.Blazor (Win-1 / Draw / Win-2 traces, theme-aware colors, accessible<details>data table fallback) - Snapshot history table beneath the chart (dd MMM HH:mm:ss + Source + rates + bet count)
- Excel export button → opens
ExportDialog, success snackbar with path
- Create
Marathon.UI/Components/SportIcon.razor— inline SVG icons per sport (basketball=6, football=11, tennis=22723, hockey=43658, generic fallback) - Create
Marathon.UI/Components/OddsCell.razor— formats decimal to two-place tabular mono numerals; ▲/▼/— delta whenPreviousdiffers; flash animation respectsprefers-reduced-motion - Create
Marathon.UI/Components/OddsTimeline.razor— wraps Plotly.Blazor with editorial-quant theming (parchment paper-bg light / ink-near-black dark, navy / amber / signal-red trace colors, mono tick fonts) plus a hidden<details>data table for screen readers; memoizes traces on signature change - Create
Marathon.UI/Components/ExportDialog.razor— modal: From/To date pickers +ExportKindradio group + Export button → callsExportToExcelUseCase. Esc cancels, Enter submits. Shows error inline when validation fails or the use case throws. - State management:
EventBrowsingState(singleton inside the RCL, per-circuit in BlazorWebView) holding immutablePageFilterrecords for PreMatch and Live; pages produce new instances and callUpdateXxx.OnChangeevent for subscribers. - Add
Plotly.Blazor5.4.1 toDirectory.Packages.propsandMarathon.UI.csproj - Append all new strings to
SharedResource.ru.resx+SharedResource.en.resxusing the Phase 5 dot-segmented convention (PreMatch.*,Live.*,Detail.*,Detail.Chart.*,Detail.History.*,Export.*,Sport.*) - Performance:
- Filter inputs debounced 300 ms via
CancellationTokenSourcererun guard - Chart data memoized via
_signature(rebuild only on count / first / last timestamp / first / last rate change) - Single in-memory list per page; small enough to skip virtualization at
Phase 6 scale;
<table>is overflow-x scrollable
- Filter inputs debounced 300 ms via
- Accessibility:
- Tables use
<thead>/<th scope="col">; sortable headers expose ▲/▼ glyphs - Rows are
tabindex="0"and respond to Enter/Space via@onkeydown - Visible amber focus rings (inherited from Phase 5
:focus-visiblerule) OddsTimelineexposes a hidden but expandable<details>/<summary>parallel data table for screen readers- Toolbar has
role="toolbar" aria-label, chips havearia-pressed
- Tables use
Files to Modify/Create
src/Marathon.UI/Pages/PreMatch/EventsList.razorsrc/Marathon.UI/Pages/Live/LiveList.razorsrc/Marathon.UI/Pages/Events/Detail.razorsrc/Marathon.UI/Components/SportIcon.razor,OddsCell.razor,OddsTimeline.razor,ExportDialog.razorsrc/Marathon.UI/Services/EventBrowsingState.cssrc/Marathon.UI/Resources/SharedResource.{ru,en}.resx— append new keyssrc/Marathon.UI/Components/_Imports.razor— register Plotly.Blazor- Tests:
tests/Marathon.UI.Tests/Pages/**,Components/**
Acceptance Criteria
- Compiles (Big Bang).
- Live list visually conveys odds changes between refreshes.
- Detail page chart renders 3 traces (Win-1/Draw/Win-2) with smooth interpolation and clear tooltip showing exact rate at any point in time.
- Excel export from the dialog reaches
ExportToExcelUseCasecorrectly. - Both RU and EN render correctly across all new UI.
- Distinctive visual identity — implementer should follow frontend-design guidance.
Notes
- The frontend-design skill content is provided to the agent in
FRONTEND_DESIGN_SKILL. Apply its principles — typography, color, motion, spatial composition. - Use Plotly.Blazor for charts (smooth, themable, professional look).
- Keep components small (<200 lines) and composable.
- Big Bang: compile-only smoke check.
Review Checklist
- Compiles (full solution clean — 0 errors, 0 warnings)
- No mutation of domain types in UI components — pages bind to view-model
records (
EventListItem,EventDetail,EventScopeBoard,BetRow,OddsTimelinePoint,SnapshotHistoryEntry) shaped inEventBrowsingService - Filters/sort persist within page session via
EventBrowsingState - Chart accessible —
<details>data table fallback inOddsTimeline - All new strings localized in RU + EN with full key parity
- Visual consistency with Phase 5 theme tokens — every color comes from
--m-c-*CSS vars or the Mud palette; no new hex literals
Test results
dotnet build Marathon.sln: ✅ 0 errors / 0 warningsdotnet test Marathon.sln: ✅ 228 passed / 0 failed (Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline was 202, +26 new bUnit tests)
Handoff to Next Phase
Component patterns Phase 7 (Anomaly UI) should reuse
| Pattern | File | Rationale |
|---|---|---|
| Section shell | Pages/Shared/EventListShell.razor |
Header (kicker + display title + lede), m-list-toolbar, m-list-table. Anomaly feed should mimic the toolbar / chips / table cadence so the surfaces feel like a series. |
| Compact data table | m-table class block in EventListShell.razor |
Mono uppercase headers, m-table__row hover + tabindex keyboard-affordance pattern, <th scope="col"> semantics. |
| Editorial header | Pages/Events/Detail.razor .m-detail-header grid |
Asymmetric 1.5fr/1fr lockup with kicker + display title + dateline on the left, summary card on the right. Ideal for an anomaly detail page. |
| Tab strip | .m-detail-tabs block in Detail.razor |
Sharp underline + amber accent active state. Anomaly detail can reuse for "Timeline" / "Evidence" / "Reasoning". |
| Asymmetric content grid | .m-detail-grid (1.2fr / 1fr) |
Pair a primary content card with an aside summary. |
| Trend indicator | Components/OddsCell.razor |
Anomaly UI's "movement at suspension" cell can drop in OddsCell directly; the Previous parameter accepts any prior value. |
| Sport branding | Components/SportIcon.razor |
Single source of sport visual language. Add new sports here, not ad-hoc. |
| Modal pattern | Components/ExportDialog.razor |
MudDialog + kicker title + grid form body + Cancel/Submit action row + inline m-export-dialog__error for validation errors. Anomaly UI may adopt the same shape for "Acknowledge" / "Mark false positive" dialogs. |
| Plotly wrapper | Components/OddsTimeline.razor |
Editorial-quant chart theme (paper-bg, mono tick fonts, navy / amber / signal-red accents). Anomaly chart should reuse the layout factory (or call into OddsTimeline directly with Points from the suspension window). |
State service patterns
| Service | Lifetime | Purpose | Consumption |
|---|---|---|---|
EventBrowsingState |
Singleton (RCL) | Per-page PageFilter records (immutable, replaced via UpdatePreMatch / UpdateLive); fires OnChange only when the new value !equals the old one. |
Pages inject + bind via @inject EventBrowsingState. |
IEventBrowsingService → EventBrowsingService |
Scoped | Repository facade returning view-model records (no EF graphs). Owns sort + in-memory filtering, latest-snapshot odds extraction, scope grouping. | Pages inject + call ListUpcomingAsync/ListLiveAsync/GetDetailAsync. |
Phase 7 should follow the same shape: an AnomalyBrowsingState singleton + an IAnomalyBrowsingService scoped facade that returns AnomalyListItem view-models with no Anomaly domain leakage.
Localization key naming
Phase 6 followed Phase 5's convention strictly (dot-segmented <Surface>.<Element>):
PreMatch.*— pre-match list page (PreMatch.Title,PreMatch.Filter.From,PreMatch.Column.Time,PreMatch.Footer.Events,PreMatch.Empty)Live.*— live list page (Live.Title,Live.AutoRefresh,Live.Lede)Detail.*— event detail page (Detail.Title,Detail.Tabs.Match,Detail.Tabs.Periodwith{0}placeholder,Detail.BetType.*,Detail.Side.*,Detail.Chart.*,Detail.Chart.AccessibleSummary,Detail.History.Title,Detail.History.Source,Detail.History.Live,Detail.History.PreMatch)Export.*— export dialog (Export.Title,Export.DateRange.From,Export.Kind.PreMatch|Live|Combined,Export.Submit,Export.Cancel,Export.Successwith{0}placeholder for path,Export.Error.MissingDates|InvalidRange|Failed)Sport.*— sport display names (Sport.Basketball,Sport.Football,Sport.Tennis,Sport.Hockey)
Phase 7 strings should slot under Anomaly.* (the Anomaly.Live /
Anomaly.Kind.SuspensionFlip / Anomaly.Score keys are already reserved
from Phase 5).
Routing additions
/prematch(existing — body replaced)/live(existing — body replaced)/events/{EventCode}(new) — accepts a URL-escapedEventId.Value(numeric for marathonbet.by; allow non-numeric for forward compatibility)
Phase 7 should add /anomalies/{eventId} or /anomalies/{anomalyId} and link
to the matching detail page from the home dashboard's "Latest signals" feed.
Theme + Plotly tokens
- Plotly traces use the same triplet as the rest of the app: navy
#0f172afor Win-1, amber#d97706for Draw, signal-red#dc2626for Win-2. Phase 7 can reuse the same trace palette for "before suspension" / "during suspension" / "after suspension" (with red as the alert tone — this is load-bearing). - Plotly.Blazor 5.4.1 is on the .NET 8 line; staying on this major avoids
the v7 breaking changes documented upstream. Phase 7's anomaly chart should
call into
OddsTimelineif possible, only forking if it needs additional axes or annotations (e.g. a vertical band for the suspension window).
Verified invariants & gotchas
Marathon.UIstill references only Domain + Application + framework packages.Plotly.Blazorwas added; it's an MIT-licensed Razor wrapper with no Infrastructure / Hosting deps, so the RCL stays host-agnostic.DateRangeambiguity: bothMudBlazor.DateRangeandMarathon.Application.Storage.DateRangeare visible inside Razor pages that import both namespaces (via_Imports.razor). Useusing AppDateRange = Marathon.Application.Storage.DateRange;in any file that calls the application'sDateRange. Already applied inExportDialog.razorandExportDialogTests.cs.- Razor source generator does not accept C# 11 raw string literals
(
"""...""") inside@codeblocks — the parser sees the leading"""as the start of a normal string and never finds the close. Use concatenated single-quoted attribute SVG strings instead (seeSportIcon.razor). codeis reserved by the Razor source generator. Loop over a list with any other identifier (@foreach (var sportCode in ...)).Plotly.Blazorexposes aPlotly.Blazor.LayoutLib.Marginthat conflicts withMudBlazor.Margin. Fully qualify the layout-side type asnew Plotly.Blazor.LayoutLib.Margin {...}.
Test infrastructure delta (for Phase 7)
tests/Marathon.UI.Tests/Support/MarathonTestContextnow also registers aFakeEventBrowsingServiceandEventBrowsingStatesingleton; Phase 7 tests can reuse both, or follow the same fake pattern for anIAnomalyBrowsingService.Support/TestData.csexposesMoscowToday(int hour),ListItem(...), andDetail(...)factories; reuse for anomaly fixtures.Support/TestOptionsMonitor<T>wrapsIOptionsMonitor<T>for tests that need to drive options-change callbacks deterministically.