Files
alexei.dolgolyov 553db2bce3 feat(phase-6): event browsing UI — pre-match/live lists, detail page, +26 bUnit tests
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.
2026-05-05 12:58:03 +03:00

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 via IEventBrowsingService
    • 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 OddsCell previews
    • Click or Enter/Space on a row → navigate to /events/{eventId}
  • 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 via IOptionsMonitor<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)
  • 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: OddsTimeline wraps 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 when Previous differs; flash animation respects prefers-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 + ExportKind radio group + Export button → calls ExportToExcelUseCase. 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 immutable PageFilter records for PreMatch and Live; pages produce new instances and call UpdateXxx. OnChange event for subscribers.
  • Add Plotly.Blazor 5.4.1 to Directory.Packages.props and Marathon.UI.csproj
  • Append all new strings to SharedResource.ru.resx + SharedResource.en.resx using the Phase 5 dot-segmented convention (PreMatch.*, Live.*, Detail.*, Detail.Chart.*, Detail.History.*, Export.*, Sport.*)
  • Performance:
    • Filter inputs debounced 300 ms via CancellationTokenSource rerun 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
  • 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-visible rule)
    • OddsTimeline exposes a hidden but expandable <details>/<summary> parallel data table for screen readers
    • Toolbar has role="toolbar" aria-label, chips have aria-pressed

Files to Modify/Create

  • src/Marathon.UI/Pages/PreMatch/EventsList.razor
  • src/Marathon.UI/Pages/Live/LiveList.razor
  • src/Marathon.UI/Pages/Events/Detail.razor
  • src/Marathon.UI/Components/SportIcon.razor, OddsCell.razor, OddsTimeline.razor, ExportDialog.razor
  • src/Marathon.UI/Services/EventBrowsingState.cs
  • src/Marathon.UI/Resources/SharedResource.{ru,en}.resx — append new keys
  • src/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 ExportToExcelUseCase correctly.
  • 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 in EventBrowsingService
  • Filters/sort persist within page session via EventBrowsingState
  • Chart accessible — <details> data table fallback in OddsTimeline
  • 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 warnings
  • dotnet 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.
IEventBrowsingServiceEventBrowsingService 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.Period with {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.Success with {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-escaped EventId.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 #0f172a for Win-1, amber #d97706 for Draw, signal-red #dc2626 for 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 OddsTimeline if possible, only forking if it needs additional axes or annotations (e.g. a vertical band for the suspension window).

Verified invariants & gotchas

  • Marathon.UI still references only Domain + Application + framework packages. Plotly.Blazor was added; it's an MIT-licensed Razor wrapper with no Infrastructure / Hosting deps, so the RCL stays host-agnostic.
  • DateRange ambiguity: both MudBlazor.DateRange and Marathon.Application.Storage.DateRange are visible inside Razor pages that import both namespaces (via _Imports.razor). Use using AppDateRange = Marathon.Application.Storage.DateRange; in any file that calls the application's DateRange. Already applied in ExportDialog.razor and ExportDialogTests.cs.
  • Razor source generator does not accept C# 11 raw string literals ("""...""") inside @code blocks — 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 (see SportIcon.razor).
  • code is reserved by the Razor source generator. Loop over a list with any other identifier (@foreach (var sportCode in ...)).
  • Plotly.Blazor exposes a Plotly.Blazor.LayoutLib.Margin that conflicts with MudBlazor.Margin. Fully qualify the layout-side type as new Plotly.Blazor.LayoutLib.Margin {...}.

Test infrastructure delta (for Phase 7)

  • tests/Marathon.UI.Tests/Support/MarathonTestContext now also registers a FakeEventBrowsingService and EventBrowsingState singleton; Phase 7 tests can reuse both, or follow the same fake pattern for an IAnomalyBrowsingService.
  • Support/TestData.cs exposes MoscowToday(int hour), ListItem(...), and Detail(...) factories; reuse for anomaly fixtures.
  • Support/TestOptionsMonitor<T> wraps IOptionsMonitor<T> for tests that need to drive options-change callbacks deterministically.