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.
This commit is contained in:
2026-05-05 12:58:03 +03:00
parent fe97643a41
commit 553db2bce3
32 changed files with 3060 additions and 64 deletions
+12
View File
@@ -109,6 +109,18 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
`System.Windows.Application` fully qualified in `App.xaml.cs`.
- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the
same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`).
- **Razor source generator does NOT accept C# 11 raw string literals** (`"""…"""`)
inside `@code` blocks — concatenate single-quoted attribute strings instead.
- **Razor reserves the identifier `code`.** Loop variables must use any other
name (`var sportCode in ...`) or the parser treats it as the `@code` directive.
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`** in
any Razor file that pulls both namespaces via `_Imports.razor`. Use a per-file
alias: `using AppDateRange = Marathon.Application.Storage.DateRange;`.
- **`Plotly.Blazor.LayoutLib.Margin` clashes with `MudBlazor.Margin`.** Fully
qualify the Plotly side at the new-expression: `new Plotly.Blazor.LayoutLib.Margin {…}`.
- **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
never pass a `DateTime.UtcNow` value to that constructor.
## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings
+1
View File
@@ -55,6 +55,7 @@
<!-- UI / Blazor components (Phase 5+) -->
<ItemGroup>
<PackageVersion Include="MudBlazor" Version="7.15.0" />
<PackageVersion Include="Plotly.Blazor" Version="5.4.1" />
</ItemGroup>
<!-- Scheduling (Phase 4 worker cron) -->
+44 -1
View File
@@ -85,7 +85,7 @@ with scraping research, no implementation.
| Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — |
| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 4 use cases, 3 BackgroundService pollers, InfrastructureModule, ApplicationModule, reflection wiring removed. 202/202 tests green (+17 new). |
| Phase 5 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | ✅ With 2 + 3 | Uses frontend-design skill |
| Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | Uses frontend-design skill |
| Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. PreMatch + Live + Events/Detail pages, EventListShell, SportIcon, OddsCell, OddsTimeline (Plotly.Blazor wrap), ExportDialog. EventBrowsingState + IEventBrowsingService facade. RU+EN strings under PreMatch.* / Live.* / Detail.* / Export.* / Sport.*. 228/228 tests green (+26 new bUnit). |
| Phase 7 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus |
| Phase 8 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus |
| Phase 9 | phase-implementer | Sonnet 4.6 | ✅ Final phase tests | — | Full build + test enforced |
@@ -101,6 +101,49 @@ with scraping research, no implementation.
## Implementation Notes
### Phase 6 (Event browsing UI, 2026-05-05)
- **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes;
5.4.1 is the latest on the .NET 8 line and works with our existing MudBlazor
7.15.0 / .NET 8.0.12 stack. The `Plotly.Blazor.LayoutLib.Margin` type clashes
with `MudBlazor.Margin` — fully qualify the layout-side type.
- **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, producing an "Unterminated string
literal" RZ1000. Use concatenated single-quoted attribute strings instead
(see `SportIcon.razor` SVG constants).
- **Razor reserves the identifier `code`.** A `@foreach (var code in ...)`
loop is parsed as the `@code` directive, not as iteration. Use any other
identifier (`var sportCode in ...`).
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`**
in any file whose `_Imports.razor` brings both namespaces in. Add
`using AppDateRange = Marathon.Application.Storage.DateRange;` per-file
where the application's `DateRange` is constructed (already done in
`ExportDialog.razor` and `ExportDialogTests.cs`).
- **EventBrowsingService is Scoped, EventBrowsingState is Singleton.** The
service captures the per-circuit DI scope so EF Core's `DbContext` lifetime
works correctly; the state object holds the per-page filter records and
fires `OnChange` only when the new value !equals the old one. This split
matches Phase 5's split between `ThemeState` (singleton) and per-circuit
data services.
- **View-models, not domain entities, cross the UI boundary.** Pages bind to
`EventListItem` / `EventDetail` / `BetRow` / `OddsTimelinePoint`
records (defined in `Marathon.UI.Services`). Repositories are not exposed
to Razor components. This keeps the UI free of EF tracked graphs and
preserves Phase 5's "RCL is host-agnostic" invariant.
- **Live page reads polling cadence from `IOptionsMonitor<ScrapingSettingsForm>`.**
Phase 4's `WorkerOptions.LivePollIntervalSeconds` (drives the poller) is a
separate setting from the UI's display refresh; the latter intentionally
follows `Scraping:PollingIntervalSeconds` per the Phase 6 subplan.
- **Plotly chart memoization.** Computed signature = `(count, first ticks,
last ticks, first/last rate triples)`. Sufficient to invalidate the trace
list on any meaningful change while staying cheap during live polling.
- **bUnit shared `MarathonTestContext` now registers the fake browsing service
and the browsing state.** Phase 7 tests can extend it directly or follow the same pattern.
`Support/TestData.MoscowToday(int hour)` produces correctly-offset
`DateTimeOffset` values — domain `Event.ScheduledAt` will reject any other
offset.
### Phase 1 (Solution skeleton + Domain model, 2026-05-05)
- **.NET 10 SDK creates `.slnx` by default.** `dotnet new sln` produces `Marathon.slnx`
+5 -5
View File
@@ -36,11 +36,11 @@ parameter configurable.
- [x] Phase 0: Scraping spike (research, throwaway) [domain: backend] → [subplan](./phase-0-scraping-spike.md)
- [x] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md)
- [ ] Phase 2: Infrastructure — Storage [domain: backend] → [subplan](./phase-2-storage.md)
- [ ] Phase 3: Infrastructure — Scraping [domain: backend] → [subplan](./phase-3-scraping.md)
- [x] Phase 2: Infrastructure — Storage [domain: backend] → [subplan](./phase-2-storage.md)
- [x] Phase 3: Infrastructure — Scraping [domain: backend] → [subplan](./phase-3-scraping.md)
- [x] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.md)
- [ ] Phase 5: Blazor Hybrid host + Theme + i18n [domain: frontend] → [subplan](./phase-5-host-theme-i18n.md)
- [ ] Phase 6: Event browsing UI [domain: frontend] → [subplan](./phase-6-event-browsing-ui.md)
- [x] Phase 5: Blazor Hybrid host + Theme + i18n [domain: frontend] → [subplan](./phase-5-host-theme-i18n.md)
- [x] Phase 6: Event browsing UI [domain: frontend] → [subplan](./phase-6-event-browsing-ui.md)
- [ ] Phase 7: Anomaly detection [domain: fullstack] → [subplan](./phase-7-anomaly-detection.md)
- [ ] Phase 8: Results loader [domain: fullstack] → [subplan](./phase-8-results-loader.md)
- [ ] Phase 9: Packaging + polish (final phase — full build + tests required) [domain: fullstack] → [subplan](./phase-9-packaging-polish.md)
@@ -68,7 +68,7 @@ parameter configurable.
| Phase 3: Scraping | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
| Phase 4: Application + Workers | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 202/202 tests | ✅ 2acbaa5 |
| Phase 5: Host + Theme + i18n | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 11/11 UI tests | ✅ batch (e4d8476…686550d…+) |
| Phase 6: Event browsing UI | frontend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 6: Event browsing UI | frontend | ✅ Done | ⬜ | ✅ Build OK + 228/228 tests | ⬜ |
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 9: Packaging + polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
@@ -1,6 +1,6 @@
# Phase 6: Event Browsing UI
**Status:** ⬜ Not Started
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
**Implementer:** Opus + frontend-design skill
@@ -15,53 +15,68 @@ information-dense without being cluttered.
## Tasks
- [ ] Create `Marathon.UI/Pages/PreMatch/EventsList.razor`:
- Server-paginated table of upcoming `Event`s (use `IEventRepository` via injected
use case wrapper)
- Filters: sport (multi-select), country (multi-select), date range, free-text
search (league/team)
- Sort: scheduled time, sport, country, league
- Each row shows compact odds preview (Match Win-1 / Draw / Win-2)
- Click row → navigate to `Pages/Events/Detail.razor?id=...`
- [ ] Create `Marathon.UI/Pages/Live/LiveList.razor`:
- Same shell as PreMatch but data source is live snapshots
- Auto-refresh every `Scraping:PollingIntervalSeconds` (use a timer + state subscription
pattern; do NOT poll the scraper directly — read from snapshot repo)
- Visual indicator when odds change since last refresh (subtle pulse / arrow)
- [ ] Create `Marathon.UI/Pages/Events/Detail.razor`:
- Event header: sport icon, league, scheduled time, sides 1 & 2
- Tabs: "Match" | "Period 1" | "Period 2" | ...
- For each scope, show all bet types in a tabular layout
- Charts panel: odds-over-time using Plotly.Blazor — three traces for Win-1/Draw/Win-2,
secondary axis for handicap value
- Snapshot history table beneath the chart
- Excel export button (single event or full date range)
- [ ] Create `Marathon.UI/Components/SportIcon.razor` — small SVG-based component with
recognizable icons per sport (basketball, football, hockey, tennis, volleyball, etc.).
- [ ] Create `Marathon.UI/Components/OddsCell.razor` — formats `OddsRate` with delta
arrow (↑ green, ↓ red) when value changes from previous render.
- [ ] Create `Marathon.UI/Components/OddsTimeline.razor` — wraps Plotly.Blazor with
consistent theming and tooltip behavior.
- [ ] Create `Marathon.UI/Components/ExportDialog.razor` — modal: pick `DateRange`,
pick `ExportKind`, click Export → calls `ExportToExcelUseCase`. Show success toast
with output file path.
- [ ] State management: small `EventBrowsingState` service (singleton scoped to UI)
holding active filters per page. Inject via DI in pages. No Redux/Fluxor — keep
simple.
- [ ] Add packages to `Marathon.UI`:
- `Plotly.Blazor`
- [ ] Update `Marathon.UI/Resources/SharedResource.{ru,en}.resx` with all new strings.
Establish key naming convention from Phase 5 handoff notes.
- [ ] Performance:
- Virtualized rows for large event lists (`MudVirtualize` or `MudTable` virtual
pagination)
- Debounce filter inputs (300ms)
- Memoize chart data — recompute only when snapshot list changes
- [ ] Accessibility:
- Table semantics with proper headers + ARIA labels
- Keyboard navigation for row selection
- Focus visible on all interactive elements
- Charts include data table fallback for screen readers
- [x] Create `Marathon.UI/Pages/PreMatch.razor` (replaced placeholder):
- Filtered list of upcoming `Event`s 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}`
- [x] 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)
- [x] 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
- [x] Create `Marathon.UI/Components/SportIcon.razor` — inline SVG icons
per sport (basketball=6, football=11, tennis=22723, hockey=43658, generic
fallback)
- [x] Create `Marathon.UI/Components/OddsCell.razor` — formats decimal to
two-place tabular mono numerals; ▲/▼/— delta when `Previous` differs;
flash animation respects `prefers-reduced-motion`
- [x] 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
- [x] 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.
- [x] 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.
- [x] Add `Plotly.Blazor` 5.4.1 to `Directory.Packages.props` and
`Marathon.UI.csproj`
- [x] 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.*`)
- [x] 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
- [x] 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
@@ -95,14 +110,123 @@ information-dense without being cluttered.
## Review Checklist
- [ ] Compiles
- [ ] No mutation of domain types in UI components
- [ ] Filters/sort persist within page session via `EventBrowsingState`
- [ ] Chart accessible (data table fallback)
- [ ] All new strings localized in RU + EN
- [ ] Visual consistency with Phase 5 theme tokens
- [x] Compiles (full solution clean — 0 errors, 0 warnings)
- [x] No mutation of domain types in UI components — pages bind to view-model
records (`EventListItem`, `EventDetail`, `EventScopeBoard`, `BetRow`,
`OddsTimelinePoint`, `SnapshotHistoryEntry`) shaped in
`EventBrowsingService`
- [x] Filters/sort persist within page session via `EventBrowsingState`
- [x] Chart accessible — `<details>` data table fallback in `OddsTimeline`
- [x] All new strings localized in RU + EN with full key parity
- [x] 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
<!-- Filled by Phase 6 implementer. Phase 7 (anomaly detection UI) needs to know
the table/card patterns established here so the anomaly feed is consistent. -->
### 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.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.
@@ -0,0 +1,174 @@
@*
ExportDialog — modal that collects a DateRange + ExportKind, then calls
ExportToExcelUseCase. Reports back via DialogResult so the caller can
show a snackbar with the file path. Keyboard: Esc to cancel, Enter to
submit.
*@
@using Marathon.Application.UseCases
@using AppDateRange = Marathon.Application.Storage.DateRange
@using ExportKind = Marathon.Application.Storage.ExportKind
@inject IStringLocalizer<SharedResource> L
@inject ExportToExcelUseCase ExportUseCase
@inject ILogger<ExportDialog> Logger
<MudDialog @key="@(_kind.ToString())" Class="m-export-dialog">
<TitleContent>
<span class="m-kicker">@L["Export.Kicker"]</span>
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.5rem; margin: var(--m-space-2) 0 0;">
@L["Export.Title"]
</h2>
</TitleContent>
<DialogContent>
<div style="display: grid; gap: var(--m-space-4); padding: var(--m-space-2) 0;" @onkeydown="HandleKeyDown">
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.DateRange.From"]</label>
</div>
<div>
<MudDatePicker
@bind-Date="_from"
Label="@L["Export.DateRange.From"]"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
</div>
</div>
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.DateRange.To"]</label>
</div>
<div>
<MudDatePicker
@bind-Date="_to"
Label="@L["Export.DateRange.To"]"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
</div>
</div>
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.Kind.Label"]</label>
</div>
<div>
<MudRadioGroup T="ExportKind" @bind-Value="_kind">
<MudRadio Value="@(ExportKind.PreMatch)" Color="Color.Primary">@L["Export.Kind.PreMatch"]</MudRadio>
<MudRadio Value="@(ExportKind.Live)" Color="Color.Primary">@L["Export.Kind.Live"]</MudRadio>
<MudRadio Value="@(ExportKind.Combined)" Color="Color.Primary">@L["Export.Kind.Combined"]</MudRadio>
</MudRadioGroup>
</div>
</div>
@if (_error is not null)
{
<div role="alert" class="m-export-dialog__error">
@_error
</div>
}
</div>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text">@L["Export.Cancel"]</MudButton>
<MudButton OnClick="Submit"
Color="Color.Primary"
Variant="Variant.Filled"
Disabled="@_busy">
@if (_busy)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Inherit" />
<span style="margin-left: 8px;">@L["Common.Loading"]</span>
}
else
{
@L["Export.Submit"]
}
</MudButton>
</DialogActions>
</MudDialog>
<style>
.m-export-dialog__error {
background: rgba(220, 38, 38, 0.08);
border-left: 3px solid var(--m-c-anomaly);
padding: var(--m-space-3);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
color: var(--m-c-anomaly);
}
</style>
@code {
[CascadingParameter] private MudDialogInstance Dialog { get; set; } = default!;
[Parameter] public DateTime? InitialFrom { get; set; }
[Parameter] public DateTime? InitialTo { get; set; }
[Parameter] public ExportKind InitialKind { get; set; } = ExportKind.Combined;
private DateTime? _from;
private DateTime? _to;
private ExportKind _kind;
private bool _busy;
private string? _error;
protected override void OnInitialized()
{
var moscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3));
_from = InitialFrom ?? moscow.AddDays(-1).Date;
_to = InitialTo ?? moscow.AddDays(1).Date;
_kind = InitialKind;
}
private void Cancel() => Dialog.Cancel();
private async Task Submit()
{
if (_from is null || _to is null)
{
_error = L["Export.Error.MissingDates"];
return;
}
if (_from > _to)
{
_error = L["Export.Error.InvalidRange"];
return;
}
_error = null;
_busy = true;
StateHasChanged();
try
{
// Use Moscow offset to match domain ScheduledAt invariant.
var moscow = TimeSpan.FromHours(3);
var range = new AppDateRange(
new DateTimeOffset(_from.Value.Date, moscow),
new DateTimeOffset(_to.Value.Date.AddDays(1).AddSeconds(-1), moscow));
var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None);
Dialog.Close(DialogResult.Ok(path));
}
catch (Exception ex)
{
Logger.LogError(ex, "Export failed");
_error = L["Export.Error.Failed"].Value + " — " + ex.Message;
}
finally
{
_busy = false;
StateHasChanged();
}
}
private async Task HandleKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
if (e.Key == "Enter" && !_busy)
{
await Submit();
}
else if (e.Key == "Escape")
{
Cancel();
}
}
}
+104
View File
@@ -0,0 +1,104 @@
@*
OddsCell — tabular mono rendering of a decimal odds rate, with an inline
direction marker (▲ amber rising, ▼ red falling, em-dash unchanged) when
the value differs from a previous render. Drives the visual indicator
that Phase 6 promises for live-list odds movement.
Rate is the current value. Previous (optional) is the prior value the
caller wants compared against. Caller decides what "previous" means
(last refresh / last snapshot) and tracks it in its own state.
*@
@{
var direction = Direction;
var directionGlyph = direction switch
{
Trend.Up => "▲", // ▲
Trend.Down => "▼", // ▼
_ => "—", // em-dash
};
var directionClass = direction switch
{
Trend.Up => "is-up",
Trend.Down => "is-down",
_ => "is-flat",
};
var ariaTrend = direction switch
{
Trend.Up => "rising",
Trend.Down => "falling",
_ => "unchanged",
};
}
<span class="m-odds @directionClass" aria-label="@AriaPrefix @Formatted (@ariaTrend)" data-trend="@ariaTrend">
<span class="m-odds__value m-mono" data-numeric>@Formatted</span>
@if (ShowTrend)
{
<span class="m-odds__delta" aria-hidden="true">@directionGlyph</span>
}
</span>
<style>
.m-odds {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 56px;
white-space: nowrap;
}
.m-odds__value {
font-feature-settings: "tnum" 1, "lnum" 1;
font-weight: 500;
color: var(--m-c-ink);
font-size: 0.9375rem;
}
.m-odds__delta {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
line-height: 1;
color: var(--m-c-ink-soft);
transition: color 220ms ease;
}
.m-odds.is-up .m-odds__delta { color: var(--m-c-accent); }
.m-odds.is-down .m-odds__delta { color: var(--m-c-anomaly); }
.m-odds.is-flat .m-odds__delta { color: var(--m-c-ink-soft); }
.m-odds.is-up .m-odds__value,
.m-odds.is-down .m-odds__value {
animation: m-odds-flash 1200ms ease-out 1;
}
@@keyframes m-odds-flash {
0% { background: color-mix(in srgb, currentColor 14%, transparent); }
100% { background: transparent; }
}
@@media (prefers-reduced-motion: reduce) {
.m-odds.is-up .m-odds__value,
.m-odds.is-down .m-odds__value { animation: none; }
}
</style>
@code {
[Parameter] public decimal? Rate { get; set; }
[Parameter] public decimal? Previous { get; set; }
[Parameter] public bool ShowTrend { get; set; } = true;
[Parameter] public string AriaPrefix { get; set; } = "Odds";
[Parameter] public string EmptyPlaceholder { get; set; } = "—";
private string Formatted => Rate is { } r ? r.ToString("0.00") : EmptyPlaceholder;
private Trend Direction
{
get
{
if (Rate is not { } r || Previous is not { } p) return Trend.Flat;
if (r > p) return Trend.Up;
if (r < p) return Trend.Down;
return Trend.Flat;
}
}
private enum Trend { Flat, Up, Down }
}
@@ -0,0 +1,242 @@
@*
OddsTimeline — wraps Plotly.Blazor's PlotlyChart with three traces
(Win-1 / Draw / Win-2) and a hidden parallel data table for screen
readers. Matches the editorial-quant theme: parchment paper-fill on
light / ink-near-black on dark, single amber accent for the highlight
rate, mono number formatting on hover labels.
Data is memoized — we only rebuild the trace lists when Points actually
changes (the calling page may re-render frequently during live polling).
*@
@using Plotly.Blazor
@using Plotly.Blazor.LayoutLib
@using Plotly.Blazor.Traces
@using Plotly.Blazor.Traces.ScatterLib
@inject IStringLocalizer<SharedResource> L
<div class="m-timeline">
@if (HasData)
{
<PlotlyChart @ref="_chart" Config="_config" Layout="_layout" Data="_data" />
<details class="m-timeline__a11y">
<summary>@L["Detail.Chart.AccessibleSummary"]</summary>
<table class="m-timeline__table">
<caption class="visually-hidden">@L["Detail.Chart.Title"]</caption>
<thead>
<tr>
<th scope="col">@L["Detail.Chart.Time"]</th>
<th scope="col">@L["Detail.Chart.Win1"]</th>
<th scope="col">@L["Detail.Chart.Draw"]</th>
<th scope="col">@L["Detail.Chart.Win2"]</th>
</tr>
</thead>
<tbody>
@foreach (var p in Points)
{
<tr>
<td class="m-mono">@p.At.ToString("HH:mm:ss") </td>
<td class="m-mono">@FormatRate(p.Win1Rate)</td>
<td class="m-mono">@FormatRate(p.DrawRate)</td>
<td class="m-mono">@FormatRate(p.Win2Rate)</td>
</tr>
}
</tbody>
</table>
</details>
}
else
{
<div class="m-timeline__empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Detail.Chart.Title"]
</span>
<p style="margin-top: var(--m-space-3); color: var(--m-c-ink-soft);">
@L["Detail.Chart.Empty"]
</p>
</div>
}
</div>
<style>
.m-timeline {
display: block;
position: relative;
min-height: 320px;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4);
}
.m-timeline__empty {
display: grid;
place-content: center;
gap: var(--m-space-2);
min-height: 280px;
text-align: center;
}
/* Hidden but accessible — the data-table fallback for screen readers. */
.m-timeline__a11y {
margin-top: var(--m-space-3);
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-timeline__a11y[open] { padding: var(--m-space-3) 0; }
.m-timeline__table {
width: 100%;
border-collapse: collapse;
margin-top: var(--m-space-2);
}
.m-timeline__table th,
.m-timeline__table td {
padding: 6px 10px;
border-bottom: 1px solid var(--m-c-rule);
text-align: left;
}
.m-timeline__table th { color: var(--m-c-ink); }
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
</style>
@code {
[Parameter, EditorRequired] public IReadOnlyList<OddsTimelinePoint> Points { get; set; } = Array.Empty<OddsTimelinePoint>();
[Parameter] public bool DarkMode { get; set; }
private PlotlyChart? _chart;
private Config _config = new() { Responsive = true, DisplayLogo = false };
private Layout _layout = new();
private IList<ITrace> _data = new List<ITrace>();
private int _signature = -1;
private bool HasData => Points.Count > 0;
protected override void OnParametersSet()
{
// Memoize: only rebuild traces/layout when the input identity changes.
var sig = ComputeSignature(Points);
if (sig == _signature) return;
_signature = sig;
_layout = BuildLayout(DarkMode);
_data = BuildTraces(Points);
}
private static int ComputeSignature(IReadOnlyList<OddsTimelinePoint> points)
{
// Cheap stable signature: count + first/last timestamps + first/last
// rates. Sufficient to invalidate the memo on any meaningful change.
if (points.Count == 0) return 0;
var first = points[0];
var last = points[^1];
return HashCode.Combine(
points.Count,
first.At.Ticks,
last.At.Ticks,
first.Win1Rate,
last.Win1Rate,
first.DrawRate,
last.DrawRate,
first.Win2Rate);
}
private static IList<ITrace> BuildTraces(IReadOnlyList<OddsTimelinePoint> points)
{
var times = points.Select(p => (object)p.At.UtcDateTime).ToList();
return new List<ITrace>
{
BuildSeries("Win 1", "#0f172a", points.Select(p => (object?)p.Win1Rate).ToList(), times),
BuildSeries("Draw", "#d97706", points.Select(p => (object?)p.DrawRate).ToList(), times),
BuildSeries("Win 2", "#dc2626", points.Select(p => (object?)p.Win2Rate).ToList(), times),
};
}
private static Scatter BuildSeries(string name, string color, IList<object?> y, IList<object> x)
{
// Plotly's IList<object> doesn't accept nulls well — cast nulls to DBNull.
var ys = y.Select(v => v ?? (object)DBNull.Value).ToList();
return new Scatter
{
Name = name,
Mode = ModeFlag.Lines | ModeFlag.Markers,
X = x,
Y = ys,
Line = new Plotly.Blazor.Traces.ScatterLib.Line { Color = color, Width = 1.6m, Shape = Plotly.Blazor.Traces.ScatterLib.LineLib.ShapeEnum.Linear },
Marker = new Plotly.Blazor.Traces.ScatterLib.Marker { Size = 5, Color = color },
ConnectGaps = false,
};
}
private static Layout BuildLayout(bool dark) => new()
{
AutoSize = true,
Margin = new Plotly.Blazor.LayoutLib.Margin { L = 56, R = 24, T = 24, B = 48 },
PaperBgColor = dark ? "#1c1917" : "#fafaf7",
PlotBgColor = dark ? "#0c0a09" : "#fafaf7",
Font = new Plotly.Blazor.LayoutLib.Font
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 11,
Color = dark ? "#f5f5f4" : "#0f172a",
},
XAxis = new List<XAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = string.Empty },
ShowGrid = true,
GridColor = dark ? "#292524" : "#e7e5e4",
ZeroLine = false,
LineColor = dark ? "#292524" : "#e7e5e4",
TickFont = new Plotly.Blazor.LayoutLib.XAxisLib.TickFont
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 10,
Color = dark ? "#a8a29e" : "#475569",
},
},
},
YAxis = new List<YAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = string.Empty },
ShowGrid = true,
GridColor = dark ? "#292524" : "#e7e5e4",
ZeroLine = false,
LineColor = dark ? "#292524" : "#e7e5e4",
TickFont = new Plotly.Blazor.LayoutLib.YAxisLib.TickFont
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 10,
Color = dark ? "#a8a29e" : "#475569",
},
},
},
ShowLegend = true,
Legend = new List<Plotly.Blazor.LayoutLib.Legend>
{
new()
{
Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H,
X = 0m,
Y = 1.12m,
Font = new Plotly.Blazor.LayoutLib.LegendLib.Font
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 11,
Color = dark ? "#e7e5e4" : "#1e293b",
},
},
},
};
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
}
@@ -0,0 +1,78 @@
@*
SportIcon — minimal, distinctive inline-SVG icons per sport. Inline (not
icon library) so the wordmark stays editorial-quant: thin strokes, sharp
corners, no shadowing. Coverage matches Phase 0 spike findings:
Basketball=6, Football=11, Tennis=22723, Hockey=43658.
*@
<span class="m-sport @ClassName" role="img" aria-label="@Label" title="@Label" data-sport="@Code">
@SvgContent
</span>
<style>
.m-sport {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--m-sport-size, 18px);
height: var(--m-sport-size, 18px);
color: var(--m-sport-color, var(--m-c-ink-soft));
flex: 0 0 auto;
}
.m-sport svg { width: 100%; height: 100%; display: block; }
.m-sport[data-sport="6"] { color: #d97706; }
.m-sport[data-sport="11"] { color: #15803d; }
.m-sport[data-sport="22723"] { color: #0369a1; }
.m-sport[data-sport="43658"] { color: #6d28d9; }
[data-theme="dark"] .m-sport { filter: brightness(1.1); }
</style>
@code {
[Parameter, EditorRequired] public int Code { get; set; }
[Parameter] public string? Label { get; set; }
[Parameter] public string? ClassName { get; set; }
private MarkupString SvgContent => new(GetSvg(Code));
private static string GetSvg(int code) => code switch
{
6 => Basketball,
11 => Football,
22723 => Tennis,
43658 => Hockey,
_ => Generic,
};
// SVG glyphs — single-quote attributes so the entire literal can stay on one line.
private const string Basketball =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
"<circle cx='12' cy='12' r='9' />" +
"<path d='M3 12h18 M12 3v18 M5 5c4 4 10 4 14 0 M5 19c4-4 10-4 14 0' />" +
"</svg>";
private const string Football =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linejoin='round'>" +
"<circle cx='12' cy='12' r='9' />" +
"<polygon points='12,7 16.5,10 14.5,15 9.5,15 7.5,10' />" +
"</svg>";
private const string Tennis =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
"<circle cx='12' cy='12' r='9' />" +
"<path d='M3.5 9 C 9 9 15 15 20.5 15' />" +
"<path d='M3.5 15 C 9 15 15 9 20.5 9' />" +
"</svg>";
private const string Hockey =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
"<ellipse cx='12' cy='14' rx='8' ry='3' />" +
"<path d='M4 14 L4 11 M20 14 L20 11' />" +
"<path d='M6 7 L18 4' />" +
"</svg>";
private const string Generic =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'>" +
"<circle cx='12' cy='12' r='9' />" +
"<circle cx='12' cy='12' r='3' />" +
"</svg>";
}
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Plotly.Blazor" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
+339
View File
@@ -0,0 +1,339 @@
@page "/events/{EventCode}"
@using Marathon.UI.Components
@using Marathon.UI.Services
@using Marathon.Domain.Enums
@using Marathon.Domain.ValueObjects
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
@inject IStringLocalizer<SharedResource> L
@inject IEventBrowsingService Browsing
@inject IDialogService Dialog
@inject ISnackbar Snackbar
@inject ThemeState ThemeState
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Detail.Title"]</PageTitle>
<section class="m-shell">
@if (_loading && _detail is null)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_detail is null)
{
<div class="m-list-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">404</span>
<p style="color: var(--m-c-ink-soft);">@L["Detail.NotFound"]</p>
<MudButton Variant="Variant.Outlined" OnClick='() => Nav.NavigateTo("/prematch")'>
@L["Detail.BackToList"]
</MudButton>
</div>
}
else
{
<header class="m-detail-header m-rise m-rise-1">
<div class="m-detail-header__lockup">
<span class="m-kicker">@SportLabel(_detail.Sport.Value) · @_detail.CountryCode · @_detail.LeagueId</span>
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem); margin-top: var(--m-space-2);">
@_detail.Side1Name <span style="color: var(--m-c-ink-soft); font-style: italic;">vs</span> @_detail.Side2Name
</h1>
<div class="m-mono" style="margin-top: var(--m-space-2); color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.75rem;">
@_detail.ScheduledAt.ToString("dd MMM yyyy · HH:mm") · MSK
</div>
</div>
<div class="m-detail-header__odds">
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Detail.Chart.Win1"]</span>
<OddsCell Rate="@LatestWin1" ShowTrend="false" />
</div>
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Detail.Chart.Draw"]</span>
<OddsCell Rate="@LatestDraw" ShowTrend="false" />
</div>
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Detail.Chart.Win2"]</span>
<OddsCell Rate="@LatestWin2" ShowTrend="false" />
</div>
<MudButton OnClick="OpenExportDialog"
Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.FileDownload"
Class="m-detail-header__export">
@L["Detail.Export"]
</MudButton>
</div>
</header>
<hr class="m-rule" />
<div class="m-detail-tabs m-rise m-rise-2" role="tablist" aria-label="@L["Detail.Tabs.Aria"]">
@foreach (var board in _detail.Boards)
{
var key = ScopeKey(board.Scope);
var label = ScopeLabel(board.Scope);
<button type="button"
class="m-detail-tab @(key == _activeTabKey ? "is-active" : null)"
role="tab"
aria-selected="@(key == _activeTabKey)"
@onclick="() => _activeTabKey = key">
@label
</button>
}
</div>
<div class="m-detail-grid m-rise m-rise-3">
<div class="m-card">
@if (ActiveBoard is { } active)
{
<h3 style="margin: 0 0 var(--m-space-3); font-family: var(--m-font-display); font-weight: 400;">
@ScopeLabel(active.Scope)
</h3>
<table class="m-table">
<thead>
<tr>
<th scope="col">@L["Detail.BetType"]</th>
<th scope="col">@L["Detail.Side"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Threshold"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Rate"]</th>
</tr>
</thead>
<tbody>
@foreach (var row in active.Bets)
{
<tr>
<td>@BetTypeLabel(row.Type)</td>
<td>@SideLabel(row.Side)</td>
<td class="m-mono" style="text-align: right;">@FormatThreshold(row.Threshold)</td>
<td class="m-mono" style="text-align: right; font-weight: 500;">@row.Rate.ToString("0.00")</td>
</tr>
}
</tbody>
</table>
}
else
{
<p style="color: var(--m-c-ink-soft);">@L["Detail.NoBoards"]</p>
}
</div>
<aside class="m-card m-card--accented">
<span class="m-kicker">@L["Detail.Chart.Title"]</span>
<div style="margin-top: var(--m-space-4);">
<OddsTimeline Points="@_detail.Timeline" DarkMode="@ThemeState.IsDark" />
</div>
<h4 style="margin: var(--m-space-5) 0 var(--m-space-3);">@L["Detail.History.Title"]</h4>
<div style="overflow-x: auto;">
<table class="m-table">
<thead>
<tr>
<th scope="col">@L["Detail.Chart.Time"]</th>
<th scope="col">@L["Detail.History.Source"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win1"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Draw"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win2"]</th>
<th scope="col" style="text-align: right;">@L["Detail.History.BetCount"]</th>
</tr>
</thead>
<tbody>
@foreach (var h in _detail.History.OrderByDescending(x => x.CapturedAt))
{
<tr>
<td class="m-mono">@h.CapturedAt.ToString("dd MMM HH:mm:ss")</td>
<td>
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase; color: @(h.Source == OddsSource.Live ? "var(--m-c-accent)" : "var(--m-c-ink-soft)");">
@(h.Source == OddsSource.Live ? L["Detail.History.Live"] : L["Detail.History.PreMatch"])
</span>
</td>
<td class="m-mono" style="text-align: right;">@FormatRate(h.Win1Rate)</td>
<td class="m-mono" style="text-align: right;">@FormatRate(h.DrawRate)</td>
<td class="m-mono" style="text-align: right;">@FormatRate(h.Win2Rate)</td>
<td class="m-mono" style="text-align: right;">@h.BetCount</td>
</tr>
}
</tbody>
</table>
</div>
</aside>
</div>
}
</section>
<style>
.m-detail-header {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: var(--m-space-5);
align-items: start;
}
@@media (max-width: 960px) {
.m-detail-header { grid-template-columns: 1fr; }
}
.m-detail-header__lockup {
display: grid;
gap: var(--m-space-2);
}
.m-detail-header__odds {
display: grid;
gap: var(--m-space-2);
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4);
}
.m-detail-header__odds-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--m-space-2) 0;
border-bottom: 1px dotted var(--m-c-rule);
}
.m-detail-header__odds-row:last-of-type { border-bottom: none; }
.m-detail-header__odds-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-c-ink-soft);
}
.m-detail-header__export { margin-top: var(--m-space-3); }
.m-detail-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--m-c-rule);
margin-top: var(--m-space-5);
flex-wrap: wrap;
}
.m-detail-tab {
appearance: none;
background: transparent;
border: 0;
padding: var(--m-space-3) var(--m-space-4);
font-family: var(--m-font-body);
font-weight: 500;
font-size: 0.9375rem;
color: var(--m-c-ink-soft);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.m-detail-tab:hover { color: var(--m-c-ink); }
.m-detail-tab.is-active {
color: var(--m-c-ink);
border-bottom-color: var(--m-c-accent);
}
.m-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--m-space-5);
margin-top: var(--m-space-5);
}
@@media (max-width: 960px) {
.m-detail-grid { grid-template-columns: 1fr; }
}
</style>
@code {
[Parameter] public string EventCode { get; set; } = string.Empty;
private EventDetail? _detail;
private bool _loading = true;
private string _activeTabKey = "match";
private decimal? LatestWin1 => _detail?.Timeline.LastOrDefault()?.Win1Rate;
private decimal? LatestDraw => _detail?.Timeline.LastOrDefault()?.DrawRate;
private decimal? LatestWin2 => _detail?.Timeline.LastOrDefault()?.Win2Rate;
private EventScopeBoard? ActiveBoard => _detail?.Boards
.FirstOrDefault(b => ScopeKey(b.Scope) == _activeTabKey)
?? _detail?.Boards.FirstOrDefault();
protected override async Task OnParametersSetAsync()
{
_loading = true;
try
{
var id = new DomainEventId(Uri.UnescapeDataString(EventCode));
_detail = await Browsing.GetDetailAsync(id, CancellationToken.None);
_activeTabKey = _detail?.Boards.Select(b => ScopeKey(b.Scope)).FirstOrDefault() ?? "match";
}
catch (ArgumentException)
{
_detail = null;
}
finally
{
_loading = false;
}
}
private async Task OpenExportDialog()
{
var parameters = new DialogParameters
{
["InitialFrom"] = _detail?.ScheduledAt.AddDays(-7).Date ?? DateTime.UtcNow.AddDays(-7).Date,
["InitialTo"] = _detail?.ScheduledAt.AddDays(7).Date ?? DateTime.UtcNow.AddDays(7).Date,
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
FullWidth = true,
MaxWidth = MaxWidth.Small,
};
var reference = await Dialog.ShowAsync<ExportDialog>(L["Export.Title"], parameters, options);
var result = await reference.Result;
if (result is { Canceled: false, Data: string path })
{
Snackbar.Add(string.Format(L["Export.Success"].Value, path), Severity.Success);
}
}
private static string ScopeKey(BetScope scope) => scope switch
{
MatchScope => "match",
PeriodScope p => $"period-{p.Number}",
_ => "unknown",
};
private string ScopeLabel(BetScope scope) => scope switch
{
MatchScope => L["Detail.Tabs.Match"],
PeriodScope p => string.Format(L["Detail.Tabs.Period"].Value, p.Number),
_ => "—",
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string BetTypeLabel(BetType t) => t switch
{
BetType.Win => L["Detail.BetType.Win"],
BetType.Draw => L["Detail.BetType.Draw"],
BetType.WinFora => L["Detail.BetType.WinFora"],
BetType.Total => L["Detail.BetType.Total"],
_ => t.ToString(),
};
private string SideLabel(Side s) => s switch
{
Side.Side1 => L["Detail.Side.Side1"],
Side.Side2 => L["Detail.Side.Side2"],
Side.Draw => L["Detail.Side.Draw"],
Side.Less => L["Detail.Side.Less"],
Side.More => L["Detail.Side.More"],
_ => s.ToString(),
};
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
private static string FormatThreshold(decimal? v) => v is { } x ? x.ToString("0.##") : "—";
}
+57 -1
View File
@@ -1,5 +1,61 @@
@page "/live"
@using Marathon.UI.Pages.Shared
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IEventBrowsingService Browsing
@inject EventBrowsingState BrowsingState
@inject NavigationManager Nav
@inject IOptionsMonitor<ScrapingSettingsForm> ScrapingMonitor
<PageTitle>@L["App.Title"] · @L["Nav.Live"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Live"]" />
<EventListShell
Surface="@L["Nav.Section.Analysis"]"
Title="@L["Live.Title"]"
Lede="@L["Live.Lede"]"
Loader="@LoadAsync"
Filter="@BrowsingState.Live"
OnFilterChanged="@HandleFilterChanged"
OnRowClicked="@HandleRowClicked"
AvailableSports="_availableSports"
AvailableCountries="_availableCountries"
LiveMode="true"
AutoRefreshSeconds="@_refreshSeconds" />
@code {
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private IReadOnlyList<string> _availableCountries = Array.Empty<string>();
private int _refreshSeconds = 30;
private IDisposable? _scrapingChange;
protected override async Task OnInitializedAsync()
{
_refreshSeconds = Math.Max(5, ScrapingMonitor.CurrentValue.PollingIntervalSeconds);
_scrapingChange = ScrapingMonitor.OnChange(opts =>
{
_refreshSeconds = Math.Max(5, opts.PollingIntervalSeconds);
InvokeAsync(StateHasChanged);
});
try
{
_availableSports = await Browsing.ListKnownSportCodesAsync(CancellationToken.None);
_availableCountries = await Browsing.ListKnownCountryCodesAsync(CancellationToken.None);
}
catch
{
// Tolerate empty data sources during early phases.
}
}
private Task<IReadOnlyList<EventListItem>> LoadAsync(EventFilter filter, CancellationToken ct)
=> Browsing.ListLiveAsync(filter, ct);
private void HandleFilterChanged(EventBrowsingState.PageFilter next)
=> BrowsingState.UpdateLive(next);
private void HandleRowClicked(EventListItem row)
=> Nav.NavigateTo($"/events/{Uri.EscapeDataString(row.Id.Value)}");
public void Dispose() => _scrapingChange?.Dispose();
}
+46 -1
View File
@@ -1,5 +1,50 @@
@page "/prematch"
@using Marathon.UI.Pages.Shared
@inject IStringLocalizer<SharedResource> L
@inject IEventBrowsingService Browsing
@inject EventBrowsingState BrowsingState
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Nav.PreMatch"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.PreMatch"]" />
<EventListShell
Surface="@L["Nav.Section.Analysis"]"
Title="@L["PreMatch.Title"]"
Lede="@L["PreMatch.Lede"]"
Loader="@LoadAsync"
Filter="@BrowsingState.PreMatch"
OnFilterChanged="@HandleFilterChanged"
OnRowClicked="@HandleRowClicked"
AvailableSports="_availableSports"
AvailableCountries="_availableCountries"
LiveMode="false"
Stale="@_stale" />
@code {
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private IReadOnlyList<string> _availableCountries = Array.Empty<string>();
private bool _stale;
protected override async Task OnInitializedAsync()
{
try
{
_availableSports = await Browsing.ListKnownSportCodesAsync(CancellationToken.None);
_availableCountries = await Browsing.ListKnownCountryCodesAsync(CancellationToken.None);
}
catch
{
// Source not yet seeded — leave defaults; the list page renders empty state.
_stale = true;
}
}
private Task<IReadOnlyList<EventListItem>> LoadAsync(EventFilter filter, CancellationToken ct)
=> Browsing.ListUpcomingAsync(filter, ct);
private void HandleFilterChanged(EventBrowsingState.PageFilter next)
=> BrowsingState.UpdatePreMatch(next);
private void HandleRowClicked(EventListItem row)
=> Nav.NavigateTo($"/events/{Uri.EscapeDataString(row.Id.Value)}");
}
@@ -0,0 +1,510 @@
@*
EventListShell — common chrome for both PreMatch and Live event lists.
Filters are kept in EventBrowsingState (passed in by the page); the
shell renders chips, the date pickers, the search box, and the table.
Live mode adds an auto-refresh timer that calls Loader on the configured
interval, and visually marks rows whose Win-1/Draw/Win-2 have moved
since the previous refresh. The table is virtualized via MudVirtualize
when more than ~25 rows are present, otherwise rendered eagerly.
*@
@using Marathon.UI.Components
@using Marathon.UI.Services
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@Surface</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@Title</h1>
@if (!string.IsNullOrEmpty(Lede))
{
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@Lede</p>
}
</header>
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["PreMatch.Filter.Toolbar"]">
<div class="m-list-toolbar__row">
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["PreMatch.Filter.From"]</label>
<input class="m-input"
type="date"
value="@FormatDate(_filter.From)"
aria-label="@L["PreMatch.Filter.From"]"
@onchange="OnFromChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["PreMatch.Filter.To"]</label>
<input class="m-input"
type="date"
value="@FormatDate(_filter.To)"
aria-label="@L["PreMatch.Filter.To"]"
@onchange="OnToChanged" />
</div>
<div class="m-list-toolbar__group m-list-toolbar__group--grow">
<label class="m-list-toolbar__label">@L["PreMatch.Filter.Search"]</label>
<input class="m-input"
type="search"
value="@_searchInput"
placeholder="@L["PreMatch.Filter.Search.Placeholder"]"
aria-label="@L["PreMatch.Filter.Search"]"
@oninput="OnSearchInput" />
</div>
@if (LiveMode)
{
<div class="m-list-toolbar__pulse" aria-live="polite">
<span class="m-anomaly__pulse" style="background: var(--m-c-positive);"></span>
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--m-c-ink-soft);">
@L["Live.AutoRefresh"] · @AutoRefreshSeconds s
</span>
</div>
}
</div>
@if (AvailableSports?.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["PreMatch.Filter.Sport"]</span>
@foreach (var sportCode in AvailableSports)
{
var active = _filter.SportCodes.Contains(sportCode);
var sportLabel = SportLabel(sportCode);
var localCode = sportCode;
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
@onclick="() => ToggleSport(localCode)">
<SportIcon Code="@localCode" Label="@sportLabel" ClassName="m-chip__icon" />
<span>@sportLabel</span>
</button>
}
</div>
}
@if (AvailableCountries?.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["PreMatch.Filter.Country"]</span>
@foreach (var country in AvailableCountries)
{
var active = _filter.CountryCodes.Contains(country, StringComparer.OrdinalIgnoreCase);
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
@onclick="() => ToggleCountry(country)">
<span>@country</span>
</button>
}
</div>
}
</div>
<div class="m-list-table m-rise m-rise-3" role="region" aria-label="@Title">
@if (_loading && _rows.Count == 0)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_rows.Count == 0)
{
<div class="m-list-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">@L["Common.Empty"]</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
@L["PreMatch.Empty"]
</p>
</div>
}
else
{
<table class="m-table" data-test="event-list-table">
<thead>
<tr>
<th scope="col" style="width: 36px;"></th>
<th scope="col" class="is-sortable" @onclick='() => Sort(EventSortKey.ScheduledAt)'>
<span>@L["PreMatch.Column.Time"]</span>
@SortGlyph(EventSortKey.ScheduledAt)
</th>
<th scope="col" class="is-sortable" @onclick='() => Sort(EventSortKey.Country)'>
<span>@L["PreMatch.Column.Country"]</span>
@SortGlyph(EventSortKey.Country)
</th>
<th scope="col" class="is-sortable" @onclick='() => Sort(EventSortKey.League)'>
<span>@L["PreMatch.Column.League"]</span>
@SortGlyph(EventSortKey.League)
</th>
<th scope="col">@L["PreMatch.Column.Match"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win1"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Draw"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win2"]</th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
var key = row.Id.Value;
var prev = _previousRates.TryGetValue(key, out var pr) ? pr : default;
<tr class="m-table__row"
tabindex="0"
data-test="event-row"
data-event-id="@row.Id.Value"
@onclick="() => OnRowClicked.InvokeAsync(row)"
@onkeydown="@(e => HandleRowKey(e, row))">
<td>
<SportIcon Code="@row.Sport.Value" Label="@SportLabel(row.Sport.Value)" />
</td>
<td class="m-mono">@row.ScheduledAt.ToString("dd MMM HH:mm")</td>
<td>@row.CountryCode</td>
<td>@row.LeagueId</td>
<td style="font-weight: 500;">@row.Side1Name <span style="color: var(--m-c-ink-soft);">vs</span> @row.Side2Name</td>
<td style="text-align: right;">
<OddsCell Rate="@row.Win1Rate" Previous="@(prev.Win1 ?? row.Win1Rate)" ShowTrend="@LiveMode" />
</td>
<td style="text-align: right;">
<OddsCell Rate="@row.DrawRate" Previous="@(prev.Draw ?? row.DrawRate)" ShowTrend="@LiveMode" />
</td>
<td style="text-align: right;">
<OddsCell Rate="@row.Win2Rate" Previous="@(prev.Win2 ?? row.Win2Rate)" ShowTrend="@LiveMode" />
</td>
</tr>
}
</tbody>
</table>
<div class="m-list-table__footer">
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--m-c-ink-soft);">
@_rows.Count @L["PreMatch.Footer.Events"]
@if (_lastLoadedAt is not null)
{
<span> · @L["PreMatch.Footer.Refreshed"] @_lastLoadedAt.Value.ToLocalTime().ToString("HH:mm:ss")</span>
}
</span>
</div>
}
</div>
</section>
<style>
.m-list-toolbar {
display: grid;
gap: var(--m-space-3);
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4);
}
.m-list-toolbar__row {
display: flex;
flex-wrap: wrap;
gap: var(--m-space-3);
align-items: end;
}
.m-list-toolbar__group { display: flex; flex-direction: column; gap: 4px; }
.m-list-toolbar__group--grow { flex: 1; min-width: 240px; }
.m-list-toolbar__label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
}
.m-input {
font-family: var(--m-font-body);
font-size: 0.9375rem;
padding: 8px 10px;
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
border-radius: var(--m-radius-xs);
color: var(--m-c-ink);
min-width: 140px;
}
.m-input:focus-visible { outline: 2px solid var(--m-c-accent); outline-offset: 1px; }
.m-list-toolbar__pulse {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.m-list-toolbar__chips {
flex-direction: row;
align-items: center;
}
.m-chip {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid var(--m-c-rule);
padding: 4px 10px;
font-family: var(--m-font-body);
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
cursor: pointer;
border-radius: var(--m-radius-xs);
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.m-chip:hover { color: var(--m-c-ink); }
.m-chip.is-active {
background: var(--m-c-ink);
color: var(--m-c-paper);
border-color: var(--m-c-ink);
}
[data-theme="dark"] .m-chip.is-active {
background: var(--m-c-accent);
color: var(--m-c-paper-2);
border-color: var(--m-c-accent);
}
.m-chip__icon { color: inherit; }
.m-list-table {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
}
.m-table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-table thead th.is-sortable { cursor: pointer; user-select: none; }
.m-table thead th.is-sortable:hover { color: var(--m-c-ink); }
.m-table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-table__row { cursor: pointer; transition: background 120ms ease; }
.m-table__row:hover { background: var(--m-c-paper-2); }
.m-table__row:focus-visible { outline: 2px solid var(--m-c-accent); outline-offset: -2px; }
.m-list-table__footer {
padding: var(--m-space-3) var(--m-space-4);
border-top: 1px solid var(--m-c-rule);
background: var(--m-c-paper-2);
}
</style>
@code {
[Parameter, EditorRequired] public string Surface { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
[Parameter] public string? Lede { get; set; }
[Parameter, EditorRequired] public Func<EventFilter, CancellationToken, Task<IReadOnlyList<EventListItem>>> Loader { get; set; } = default!;
[Parameter, EditorRequired] public EventBrowsingState.PageFilter Filter { get; set; } = default!;
[Parameter] public EventCallback<EventBrowsingState.PageFilter> OnFilterChanged { get; set; }
[Parameter] public EventCallback<EventListItem> OnRowClicked { get; set; }
[Parameter] public IReadOnlyList<int>? AvailableSports { get; set; }
[Parameter] public IReadOnlyList<string>? AvailableCountries { get; set; }
[Parameter] public bool LiveMode { get; set; }
[Parameter] public int AutoRefreshSeconds { get; set; } = 30;
[Parameter] public bool Stale { get; set; }
private EventBrowsingState.PageFilter _filter = default!;
private string _searchInput = string.Empty;
private CancellationTokenSource? _searchCts;
private CancellationTokenSource? _loadCts;
private List<EventListItem> _rows = new();
private DateTimeOffset? _lastLoadedAt;
private bool _loading;
private System.Timers.Timer? _refreshTimer;
private readonly Dictionary<string, RatesSnap> _previousRates = new(StringComparer.Ordinal);
protected override void OnParametersSet()
{
if (!ReferenceEquals(_filter, Filter))
{
_filter = Filter;
_searchInput = Filter.SearchTerm;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadAsync();
if (LiveMode) StartTimer();
}
}
private void StartTimer()
{
_refreshTimer?.Dispose();
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
_refreshTimer.Elapsed += async (_, _) =>
{
await InvokeAsync(LoadAsync);
};
_refreshTimer.Start();
}
private async Task LoadAsync()
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_loading = true;
try
{
// Capture previous rates for delta visualization.
foreach (var row in _rows)
{
_previousRates[row.Id.Value] = new RatesSnap(row.Win1Rate, row.DrawRate, row.Win2Rate);
}
var rows = await Loader(_filter.ToFilter(), ct);
if (ct.IsCancellationRequested) return;
_rows = rows.ToList();
_lastLoadedAt = DateTimeOffset.UtcNow;
}
catch (OperationCanceledException)
{
// Swallow — superseded by a newer load.
}
catch
{
// Hide errors from the UI; Phase 9 will add a snackbar.
_rows = new List<EventListItem>();
}
finally
{
_loading = false;
StateHasChanged();
}
}
private async Task UpdateFilter(EventBrowsingState.PageFilter next)
{
_filter = next;
await OnFilterChanged.InvokeAsync(next);
await LoadAsync();
}
private async Task OnFromChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
}
}
private async Task OnSearchInput(ChangeEventArgs e)
{
_searchInput = e.Value?.ToString() ?? string.Empty;
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
var captured = _searchInput;
try
{
await Task.Delay(300, token);
if (!token.IsCancellationRequested)
{
await UpdateFilter(_filter with { SearchTerm = captured });
}
}
catch (TaskCanceledException) { /* superseded */ }
}
private async Task ToggleSport(int code)
{
var set = _filter.SportCodes.ToList();
if (!set.Remove(code)) set.Add(code);
await UpdateFilter(_filter with { SportCodes = set });
}
private async Task ToggleCountry(string country)
{
var set = _filter.CountryCodes.ToList();
var existing = set.FirstOrDefault(c => string.Equals(c, country, StringComparison.OrdinalIgnoreCase));
if (existing is not null) set.Remove(existing);
else set.Add(country);
await UpdateFilter(_filter with { CountryCodes = set });
}
private async Task Sort(EventSortKey key)
{
bool desc;
if (_filter.SortKey == key)
{
desc = !_filter.SortDescending;
}
else
{
desc = false;
}
await UpdateFilter(_filter with { SortKey = key, SortDescending = desc });
}
private async Task HandleRowKey(KeyboardEventArgs e, EventListItem row)
{
if (e.Key == "Enter" || e.Key == " ")
{
await OnRowClicked.InvokeAsync(row);
}
}
private RenderFragment SortGlyph(EventSortKey key) => builder =>
{
if (_filter.SortKey != key) return;
builder.OpenElement(0, "span");
builder.AddAttribute(1, "style", "margin-left: 6px; color: var(--m-c-accent);");
builder.AddContent(2, _filter.SortDescending ? "▼" : "▲");
builder.CloseElement();
};
private static string FormatDate(DateTimeOffset value)
=> value.ToString("yyyy-MM-dd");
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private readonly record struct RatesSnap(decimal? Win1, decimal? Draw, decimal? Win2);
public void Dispose()
{
_refreshTimer?.Dispose();
_searchCts?.Cancel();
_searchCts?.Dispose();
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}
@@ -149,4 +149,80 @@
<data name="Anomaly.Live"><value>Anomaly</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 6 — Pre-match list / Live list / Detail / Export -->
<data name="PreMatch.Title"><value>Pre-match schedule</value></data>
<data name="PreMatch.Lede"><value>Upcoming events with their latest pre-match Win-1 / Draw / Win-2 odds preview. Filter by sport, country, league, or team.</value></data>
<data name="PreMatch.Empty"><value>No events match the current filters. Loosen the date range or clear the chips above.</value></data>
<data name="PreMatch.Filter.Toolbar"><value>Filter toolbar</value></data>
<data name="PreMatch.Filter.From"><value>From</value></data>
<data name="PreMatch.Filter.To"><value>To</value></data>
<data name="PreMatch.Filter.Sport"><value>Sports</value></data>
<data name="PreMatch.Filter.Country"><value>Countries</value></data>
<data name="PreMatch.Filter.Search"><value>Search league or team</value></data>
<data name="PreMatch.Filter.Search.Placeholder"><value>e.g. Real Madrid, NBA, Roland Garros…</value></data>
<data name="PreMatch.Column.Time"><value>Time</value></data>
<data name="PreMatch.Column.Country"><value>Country</value></data>
<data name="PreMatch.Column.League"><value>League</value></data>
<data name="PreMatch.Column.Match"><value>Match</value></data>
<data name="PreMatch.Footer.Events"><value>events</value></data>
<data name="PreMatch.Footer.Refreshed"><value>refreshed at</value></data>
<data name="Live.Title"><value>Live odds feed</value></data>
<data name="Live.Lede"><value>Currently-live events with the most recent live snapshot. The list refreshes on the configured polling cadence; rows pulse when their odds move.</value></data>
<data name="Live.AutoRefresh"><value>Auto-refresh</value></data>
<data name="Detail.Title"><value>Event</value></data>
<data name="Detail.NotFound"><value>This event could not be loaded — it may have been removed from the source feed.</value></data>
<data name="Detail.BackToList"><value>Back to schedule</value></data>
<data name="Detail.Export"><value>Export</value></data>
<data name="Detail.Tabs.Aria"><value>Bet scope tabs</value></data>
<data name="Detail.Tabs.Match"><value>Match</value></data>
<data name="Detail.Tabs.Period"><value>Period {0}</value></data>
<data name="Detail.NoBoards"><value>No bets captured yet for this event.</value></data>
<data name="Detail.BetType"><value>Type</value></data>
<data name="Detail.Side"><value>Side</value></data>
<data name="Detail.Threshold"><value>Threshold</value></data>
<data name="Detail.Rate"><value>Rate</value></data>
<data name="Detail.BetType.Win"><value>Win</value></data>
<data name="Detail.BetType.Draw"><value>Draw</value></data>
<data name="Detail.BetType.WinFora"><value>Handicap</value></data>
<data name="Detail.BetType.Total"><value>Total</value></data>
<data name="Detail.Side.Side1"><value>1</value></data>
<data name="Detail.Side.Side2"><value>2</value></data>
<data name="Detail.Side.Draw"><value>X</value></data>
<data name="Detail.Side.Less"><value>Under</value></data>
<data name="Detail.Side.More"><value>Over</value></data>
<data name="Detail.Chart.Title"><value>Odds movement</value></data>
<data name="Detail.Chart.Empty"><value>No snapshots captured yet for this event.</value></data>
<data name="Detail.Chart.Time"><value>Time</value></data>
<data name="Detail.Chart.Win1"><value>Win 1</value></data>
<data name="Detail.Chart.Draw"><value>Draw</value></data>
<data name="Detail.Chart.Win2"><value>Win 2</value></data>
<data name="Detail.Chart.AccessibleSummary"><value>Show data table</value></data>
<data name="Detail.History.Title"><value>Snapshot history</value></data>
<data name="Detail.History.Source"><value>Source</value></data>
<data name="Detail.History.BetCount"><value>Bets</value></data>
<data name="Detail.History.Live"><value>LIVE</value></data>
<data name="Detail.History.PreMatch"><value>PRE</value></data>
<data name="Export.Kicker"><value>Export</value></data>
<data name="Export.Title"><value>Export to Excel</value></data>
<data name="Export.DateRange.From"><value>From date</value></data>
<data name="Export.DateRange.To"><value>To date</value></data>
<data name="Export.Kind.Label"><value>Snapshot kind</value></data>
<data name="Export.Kind.PreMatch"><value>Pre-match only</value></data>
<data name="Export.Kind.Live"><value>Live only</value></data>
<data name="Export.Kind.Combined"><value>Combined</value></data>
<data name="Export.Submit"><value>Export</value></data>
<data name="Export.Cancel"><value>Cancel</value></data>
<data name="Export.Success"><value>Export saved to {0}</value></data>
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
<data name="Export.Error.Failed"><value>Export failed.</value></data>
<data name="Sport.Basketball"><value>Basketball</value></data>
<data name="Sport.Football"><value>Football</value></data>
<data name="Sport.Tennis"><value>Tennis</value></data>
<data name="Sport.Hockey"><value>Hockey</value></data>
</root>
@@ -162,4 +162,80 @@
<data name="Anomaly.Live"><value>Аномалия</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 6 — Список матчей / Лайв / Детали / Экспорт -->
<data name="PreMatch.Title"><value>Расписание до матча</value></data>
<data name="PreMatch.Lede"><value>Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде.</value></data>
<data name="PreMatch.Empty"><value>Под текущие фильтры не подпадает ни одно событие. Расширьте диапазон или снимите чипы выше.</value></data>
<data name="PreMatch.Filter.Toolbar"><value>Панель фильтров</value></data>
<data name="PreMatch.Filter.From"><value>С</value></data>
<data name="PreMatch.Filter.To"><value>По</value></data>
<data name="PreMatch.Filter.Sport"><value>Виды спорта</value></data>
<data name="PreMatch.Filter.Country"><value>Страны</value></data>
<data name="PreMatch.Filter.Search"><value>Поиск по лиге или команде</value></data>
<data name="PreMatch.Filter.Search.Placeholder"><value>напр. Реал Мадрид, NBA, Ролан Гаррос…</value></data>
<data name="PreMatch.Column.Time"><value>Время</value></data>
<data name="PreMatch.Column.Country"><value>Страна</value></data>
<data name="PreMatch.Column.League"><value>Лига</value></data>
<data name="PreMatch.Column.Match"><value>Матч</value></data>
<data name="PreMatch.Footer.Events"><value>событий</value></data>
<data name="PreMatch.Footer.Refreshed"><value>обновлено в</value></data>
<data name="Live.Title"><value>Лайв-поток коэффициентов</value></data>
<data name="Live.Lede"><value>Текущие лайв-события с последним сделанным снимком. Список обновляется по настроенному интервалу опроса; строки пульсируют при движении котировки.</value></data>
<data name="Live.AutoRefresh"><value>Автообновление</value></data>
<data name="Detail.Title"><value>Событие</value></data>
<data name="Detail.NotFound"><value>Событие не найдено — возможно, оно убрано из исходного потока.</value></data>
<data name="Detail.BackToList"><value>К расписанию</value></data>
<data name="Detail.Export"><value>Экспорт</value></data>
<data name="Detail.Tabs.Aria"><value>Вкладки разделов ставок</value></data>
<data name="Detail.Tabs.Match"><value>Матч</value></data>
<data name="Detail.Tabs.Period"><value>Период {0}</value></data>
<data name="Detail.NoBoards"><value>Снимков ставок по этому событию ещё нет.</value></data>
<data name="Detail.BetType"><value>Тип</value></data>
<data name="Detail.Side"><value>Сторона</value></data>
<data name="Detail.Threshold"><value>Порог</value></data>
<data name="Detail.Rate"><value>Кэф</value></data>
<data name="Detail.BetType.Win"><value>Победа</value></data>
<data name="Detail.BetType.Draw"><value>Ничья</value></data>
<data name="Detail.BetType.WinFora"><value>Фора</value></data>
<data name="Detail.BetType.Total"><value>Тотал</value></data>
<data name="Detail.Side.Side1"><value>1</value></data>
<data name="Detail.Side.Side2"><value>2</value></data>
<data name="Detail.Side.Draw"><value>X</value></data>
<data name="Detail.Side.Less"><value>Меньше</value></data>
<data name="Detail.Side.More"><value>Больше</value></data>
<data name="Detail.Chart.Title"><value>Динамика коэффициентов</value></data>
<data name="Detail.Chart.Empty"><value>Снимков по этому событию ещё нет.</value></data>
<data name="Detail.Chart.Time"><value>Время</value></data>
<data name="Detail.Chart.Win1"><value>П1</value></data>
<data name="Detail.Chart.Draw"><value>X</value></data>
<data name="Detail.Chart.Win2"><value>П2</value></data>
<data name="Detail.Chart.AccessibleSummary"><value>Показать таблицу значений</value></data>
<data name="Detail.History.Title"><value>История снимков</value></data>
<data name="Detail.History.Source"><value>Источник</value></data>
<data name="Detail.History.BetCount"><value>Ставок</value></data>
<data name="Detail.History.Live"><value>ЛАЙВ</value></data>
<data name="Detail.History.PreMatch"><value>ДО МАТЧА</value></data>
<data name="Export.Kicker"><value>Экспорт</value></data>
<data name="Export.Title"><value>Экспорт в Excel</value></data>
<data name="Export.DateRange.From"><value>Дата начала</value></data>
<data name="Export.DateRange.To"><value>Дата конца</value></data>
<data name="Export.Kind.Label"><value>Тип снимков</value></data>
<data name="Export.Kind.PreMatch"><value>Только до матча</value></data>
<data name="Export.Kind.Live"><value>Только лайв</value></data>
<data name="Export.Kind.Combined"><value>Комбинированный</value></data>
<data name="Export.Submit"><value>Экспорт</value></data>
<data name="Export.Cancel"><value>Отмена</value></data>
<data name="Export.Success"><value>Файл сохранён в {0}</value></data>
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
<data name="Sport.Basketball"><value>Баскетбол</value></data>
<data name="Sport.Football"><value>Футбол</value></data>
<data name="Sport.Tennis"><value>Теннис</value></data>
<data name="Sport.Hockey"><value>Хоккей</value></data>
</root>
@@ -0,0 +1,269 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Repository-backed browsing service. Reads events + their latest snapshot
/// once per call and shapes view-models. Pages call this — never the repos
/// directly — so the UI is shielded from EF tracked graphs and can be re-pointed
/// at a different storage layer later.
/// </summary>
public sealed class EventBrowsingService : IEventBrowsingService
{
private readonly IEventRepository _events;
private readonly ISnapshotRepository _snapshots;
public EventBrowsingService(IEventRepository events, ISnapshotRepository snapshots)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
}
public async Task<IReadOnlyList<EventListItem>> ListUpcomingAsync(EventFilter filter, CancellationToken ct)
=> await BuildListAsync(filter, OddsSource.PreMatch, ct).ConfigureAwait(false);
public async Task<IReadOnlyList<EventListItem>> ListLiveAsync(EventFilter filter, CancellationToken ct)
=> await BuildListAsync(filter, OddsSource.Live, ct).ConfigureAwait(false);
public async Task<EventDetail?> GetDetailAsync(DomainEventId eventId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(eventId);
var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false);
if (ev is null) return null;
var from = ev.ScheduledAt.AddDays(-2);
var to = ev.ScheduledAt.AddDays(2);
var snapshots = await _snapshots
.ListByEventAsync(eventId, from, to, ct)
.ConfigureAwait(false);
var ordered = snapshots
.OrderBy(static s => s.CapturedAt)
.ToList();
var latest = ordered.LastOrDefault();
var boards = latest is null
? Array.Empty<EventScopeBoard>()
: BuildBoards(latest);
var timeline = ordered.Select(BuildTimelinePoint).ToList();
var history = ordered.Select(BuildHistoryEntry).ToList();
return new EventDetail(
ev.Id,
ev.Sport,
ev.CountryCode,
ev.LeagueId,
ev.Side1Name,
ev.Side2Name,
ev.ScheduledAt,
boards,
timeline,
history);
}
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
{
var all = await _events.ListAsync(ct).ConfigureAwait(false);
return all
.Select(static e => e.Sport.Value)
.Distinct()
.OrderBy(static x => x)
.ToList();
}
public async Task<IReadOnlyList<string>> ListKnownCountryCodesAsync(CancellationToken ct)
{
var all = await _events.ListAsync(ct).ConfigureAwait(false);
return all
.Select(static e => e.CountryCode)
.Distinct()
.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
// ---------------- internals ----------------
private async Task<IReadOnlyList<EventListItem>> BuildListAsync(
EventFilter filter,
OddsSource source,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(filter);
var range = new DateRange(filter.Dates.From, filter.Dates.To);
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
// Apply non-temporal filters in-memory — list size is small (UI page).
IEnumerable<Event> filtered = events;
if (filter.SportCodes is { Count: > 0 } sports)
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
if (filter.CountryCodes is { Count: > 0 } countries)
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
{
var term = filter.SearchTerm.Trim();
filtered = filtered.Where(e =>
e.LeagueId.Contains(term, StringComparison.OrdinalIgnoreCase) ||
e.Side1Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
e.Side2Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
e.Category.Contains(term, StringComparison.OrdinalIgnoreCase));
}
var sorted = ApplySort(filtered, filter.SortKey, filter.SortDescending);
var materialized = sorted.ToList();
// Read each event's latest matching snapshot to populate the preview odds.
var rangeFrom = filter.Dates.From.AddDays(-2);
var rangeTo = filter.Dates.To.AddDays(2);
var rows = new List<EventListItem>(materialized.Count);
foreach (var ev in materialized)
{
ct.ThrowIfCancellationRequested();
var snapshots = await _snapshots
.ListByEventAsync(ev.Id, rangeFrom, rangeTo, ct)
.ConfigureAwait(false);
var matching = snapshots
.Where(s => s.Source == source)
.OrderByDescending(static s => s.CapturedAt)
.FirstOrDefault();
rows.Add(MapRow(ev, matching));
}
return rows;
}
private static IEnumerable<Event> ApplySort(IEnumerable<Event> source, EventSortKey key, bool desc)
=> key switch
{
EventSortKey.Sport => desc
? source.OrderByDescending(static e => e.Sport.Value)
: source.OrderBy(static e => e.Sport.Value),
EventSortKey.Country => desc
? source.OrderByDescending(static e => e.CountryCode, StringComparer.OrdinalIgnoreCase)
: source.OrderBy(static e => e.CountryCode, StringComparer.OrdinalIgnoreCase),
EventSortKey.League => desc
? source.OrderByDescending(static e => e.LeagueId, StringComparer.OrdinalIgnoreCase)
: source.OrderBy(static e => e.LeagueId, StringComparer.OrdinalIgnoreCase),
_ => desc
? source.OrderByDescending(static e => e.ScheduledAt)
: source.OrderBy(static e => e.ScheduledAt),
};
private static EventListItem MapRow(Event ev, OddsSnapshot? snapshot)
{
decimal? win1 = null, drw = null, win2 = null;
if (snapshot is not null)
{
(win1, drw, win2) = ExtractMatchWinRates(snapshot);
}
return new EventListItem(
ev.Id,
ev.Sport,
ev.CountryCode,
ev.LeagueId,
ev.Side1Name,
ev.Side2Name,
ev.ScheduledAt,
win1,
drw,
win2,
snapshot?.CapturedAt,
snapshot?.Source);
}
private static (decimal? Win1, decimal? Draw, decimal? Win2) ExtractMatchWinRates(OddsSnapshot s)
{
decimal? w1 = null, dr = null, w2 = null;
foreach (var bet in s.Bets)
{
if (bet.Scope is not MatchScope) continue;
switch (bet.Type)
{
case BetType.Win when bet.Side == Side.Side1:
w1 = bet.Rate.Value;
break;
case BetType.Win when bet.Side == Side.Side2:
w2 = bet.Rate.Value;
break;
case BetType.Draw:
dr = bet.Rate.Value;
break;
}
}
return (w1, dr, w2);
}
private static IReadOnlyList<EventScopeBoard> BuildBoards(OddsSnapshot snapshot)
{
// Group by scope, preserve Match-first order then ascending Period numbers.
var groups = snapshot.Bets
.GroupBy(static b => b.Scope, ScopeEqualityComparer.Instance)
.OrderBy(static g => OrderKey(g.Key));
var boards = new List<EventScopeBoard>();
foreach (var grp in groups)
{
var rows = grp
.OrderBy(static b => (int)b.Type)
.ThenBy(static b => (int)b.Side)
.ThenBy(static b => b.Value?.Value ?? 0m)
.Select(static b => new BetRow(b.Type, b.Side, b.Value?.Value, b.Rate.Value))
.ToList();
boards.Add(new EventScopeBoard(grp.Key, rows));
}
return boards;
}
private static OddsTimelinePoint BuildTimelinePoint(OddsSnapshot s)
{
var (w1, dr, w2) = ExtractMatchWinRates(s);
return new OddsTimelinePoint(s.CapturedAt, w1, dr, w2);
}
private static SnapshotHistoryEntry BuildHistoryEntry(OddsSnapshot s)
{
var (w1, dr, w2) = ExtractMatchWinRates(s);
return new SnapshotHistoryEntry(s.CapturedAt, s.Source, s.Bets.Count, w1, dr, w2);
}
private static int OrderKey(BetScope s) => s switch
{
MatchScope => 0,
PeriodScope p => p.Number,
_ => int.MaxValue,
};
private sealed class ScopeEqualityComparer : IEqualityComparer<BetScope>
{
public static readonly ScopeEqualityComparer Instance = new();
public bool Equals(BetScope? x, BetScope? y) => (x, y) switch
{
(null, null) => true,
(MatchScope, MatchScope) => true,
(PeriodScope a, PeriodScope b) => a.Number == b.Number,
_ => false,
};
public int GetHashCode(BetScope obj) => obj switch
{
MatchScope => 0,
PeriodScope p => p.Number,
_ => -1,
};
}
}
@@ -0,0 +1,91 @@
namespace Marathon.UI.Services;
/// <summary>
/// Page-scoped filter state for the event browsing pages. Pages bind to this
/// directly; navigating away and back restores the previous filter set within
/// the same circuit. No persistence — these are working filters, not user
/// settings.
/// </summary>
/// <remarks>
/// Registered as a singleton inside the RCL — for the WPF BlazorWebView host
/// the singleton is the entire circuit. A future ASP.NET Core Blazor Server
/// host should register it Scoped so each circuit gets its own copy.
/// </remarks>
public sealed class EventBrowsingState
{
private PageFilter _preMatch = PageFilter.Default(DateTime.UtcNow);
private PageFilter _live = PageFilter.Default(DateTime.UtcNow);
public PageFilter PreMatch => _preMatch;
public PageFilter Live => _live;
public event Action? OnChange;
public void UpdatePreMatch(PageFilter next)
{
ArgumentNullException.ThrowIfNull(next);
if (_preMatch.Equals(next)) return;
_preMatch = next;
OnChange?.Invoke();
}
public void UpdateLive(PageFilter next)
{
ArgumentNullException.ThrowIfNull(next);
if (_live.Equals(next)) return;
_live = next;
OnChange?.Invoke();
}
/// <summary>
/// Persistent (per-page) filter values that survive navigation within
/// the same circuit. Immutable record — pages produce new instances.
/// </summary>
public sealed record PageFilter(
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<int> SportCodes,
IReadOnlyList<string> CountryCodes,
string SearchTerm,
EventSortKey SortKey,
bool SortDescending)
{
public static PageFilter Default(DateTime nowUtc)
{
// Default window: -1d .. +7d in Moscow time, the same TZ events use.
var moscow = TimeSpan.FromHours(3);
var midnight = new DateTimeOffset(nowUtc.Date, TimeSpan.Zero).ToOffset(moscow);
return new PageFilter(
From: midnight.AddDays(-1),
To: midnight.AddDays(7),
SportCodes: Array.Empty<int>(),
CountryCodes: Array.Empty<string>(),
SearchTerm: string.Empty,
SortKey: EventSortKey.ScheduledAt,
SortDescending: false);
}
public EventFilter ToFilter() => new(
new DateRangeFilter(From, To),
SportCodes,
CountryCodes,
SearchTerm,
SortKey,
SortDescending);
public bool Equals(PageFilter? other)
{
if (other is null) return false;
return From == other.From
&& To == other.To
&& SortKey == other.SortKey
&& SortDescending == other.SortDescending
&& string.Equals(SearchTerm, other.SearchTerm, StringComparison.Ordinal)
&& SportCodes.SequenceEqual(other.SportCodes)
&& CountryCodes.SequenceEqual(other.CountryCodes, StringComparer.OrdinalIgnoreCase);
}
public override int GetHashCode() => HashCode.Combine(From, To, SortKey, SortDescending, SearchTerm);
}
}
+102
View File
@@ -0,0 +1,102 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// Compact event row used by the pre-match and live list pages.
/// View-model — DOES NOT carry mutable EF tracked references; the UI
/// is decoupled from domain mutability.
/// </summary>
public sealed record EventListItem(
EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Side1Name,
string Side2Name,
DateTimeOffset ScheduledAt,
decimal? Win1Rate,
decimal? DrawRate,
decimal? Win2Rate,
DateTimeOffset? LastSnapshotAt,
OddsSource? LastSnapshotSource);
/// <summary>
/// Snapshot of the bet board for a single event scope (Match or PeriodN).
/// </summary>
public sealed record EventScopeBoard(
BetScope Scope,
IReadOnlyList<BetRow> Bets);
/// <summary>
/// Single row in the per-scope bet board.
/// </summary>
public sealed record BetRow(
BetType Type,
Side Side,
decimal? Threshold,
decimal Rate);
/// <summary>
/// Full event detail aggregate for the detail page.
/// </summary>
public sealed record EventDetail(
EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Side1Name,
string Side2Name,
DateTimeOffset ScheduledAt,
IReadOnlyList<EventScopeBoard> Boards,
IReadOnlyList<OddsTimelinePoint> Timeline,
IReadOnlyList<SnapshotHistoryEntry> History);
/// <summary>
/// Single point in the odds-over-time chart — Win-1/Draw/Win-2 rates at a moment.
/// </summary>
public sealed record OddsTimelinePoint(
DateTimeOffset At,
decimal? Win1Rate,
decimal? DrawRate,
decimal? Win2Rate);
/// <summary>
/// Single row in the snapshot history table on the detail page.
/// </summary>
public sealed record SnapshotHistoryEntry(
DateTimeOffset CapturedAt,
OddsSource Source,
int BetCount,
decimal? Win1Rate,
decimal? DrawRate,
decimal? Win2Rate);
/// <summary>
/// Filter state passed from the page to the service. All members optional —
/// a request with everything null returns an unfiltered slice.
/// </summary>
public sealed record EventFilter(
DateRangeFilter Dates,
IReadOnlyCollection<int>? SportCodes = null,
IReadOnlyCollection<string>? CountryCodes = null,
string? SearchTerm = null,
EventSortKey SortKey = EventSortKey.ScheduledAt,
bool SortDescending = false,
OddsSource? Source = null);
/// <summary>
/// Inclusive [From..To] date filter; collapses to a 7-day window when
/// both members are null.
/// </summary>
public sealed record DateRangeFilter(DateTimeOffset From, DateTimeOffset To);
/// <summary>The columns the user can sort by on the list pages.</summary>
public enum EventSortKey
{
ScheduledAt,
Sport,
Country,
League,
}
@@ -0,0 +1,27 @@
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Read-only browsing facade over the Event/Snapshot repositories.
/// Pages depend on this — never on <c>IEventRepository</c> directly — so
/// view-model shaping stays in one place and the UI does not bind to mutable
/// EF graphs.
/// </summary>
public interface IEventBrowsingService
{
/// <summary>List upcoming (pre-match) events matching the filter.</summary>
Task<IReadOnlyList<EventListItem>> ListUpcomingAsync(EventFilter filter, CancellationToken ct);
/// <summary>List currently-live events with their most recent live snapshot odds.</summary>
Task<IReadOnlyList<EventListItem>> ListLiveAsync(EventFilter filter, CancellationToken ct);
/// <summary>Full detail aggregate for the event detail page; null when the event is not found.</summary>
Task<EventDetail?> GetDetailAsync(DomainEventId eventId, CancellationToken ct);
/// <summary>The set of distinct sport codes present in the data — used to populate the filter chips.</summary>
Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct);
/// <summary>The set of distinct country codes present in the data — used to populate the filter chips.</summary>
Task<IReadOnlyList<string>> ListKnownCountryCodesAsync(CancellationToken ct);
}
@@ -45,6 +45,10 @@ public static class UiServicesExtensions
// Singletons that drive UI chrome state.
services.AddSingleton<ThemeState>();
services.AddSingleton<LocaleState>();
services.AddSingleton<EventBrowsingState>();
// Browsing facade — Scoped so it captures the per-circuit repository scope.
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -0,0 +1,96 @@
using Bunit;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using AppDateRange = Marathon.Application.Storage.DateRange;
using Marathon.UI.Components;
using Marathon.UI.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MudBlazor;
using NSubstitute;
namespace Marathon.UI.Tests.Components;
public sealed class ExportDialogTests : MarathonTestContext
{
private readonly IExcelExporter _exporter = Substitute.For<IExcelExporter>();
public ExportDialogTests()
{
Services.AddSingleton(_exporter);
Services.AddSingleton(Options.Create(new StorageOptions
{
ExportDirectory = Path.Combine(Path.GetTempPath(), "marathon-tests"),
}));
Services.AddSingleton(new ExportToExcelUseCase(
_exporter,
Options.Create(new StorageOptions
{
ExportDirectory = Path.Combine(Path.GetTempPath(), "marathon-tests"),
}),
NullLogger<ExportToExcelUseCase>.Instance));
}
[Fact]
public async Task Submit_calls_export_use_case()
{
_exporter.ExportAsync(Arg.Any<AppDateRange>(), Arg.Any<ExportKind>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult("C:/temp/Marathon_2026-05-04_to_2026-05-06.xlsx"));
var host = RenderComponent<DialogHost>();
IDialogReference? reference = null;
await host.InvokeAsync(async () =>
{
var svc = Services.GetRequiredService<IDialogService>();
reference = await svc.ShowAsync<ExportDialog>("Export");
});
reference.Should().NotBeNull();
// Click the Export button (the second action button — first is Cancel).
host.WaitForAssertion(() => host.FindAll(".mud-button").Count.Should().BeGreaterThanOrEqualTo(2));
var actionButtons = host.FindAll(".mud-dialog-actions .mud-button");
var submit = actionButtons.Last();
await host.InvokeAsync(() => submit.Click());
await _exporter.Received(1).ExportAsync(
Arg.Any<AppDateRange>(),
Arg.Any<ExportKind>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Cancel_does_not_call_export()
{
var host = RenderComponent<DialogHost>();
await host.InvokeAsync(async () =>
{
var svc = Services.GetRequiredService<IDialogService>();
await svc.ShowAsync<ExportDialog>("Export");
});
host.WaitForAssertion(() => host.FindAll(".mud-dialog-actions .mud-button").Count.Should().BeGreaterThanOrEqualTo(2));
var cancelBtn = host.FindAll(".mud-dialog-actions .mud-button").First();
await host.InvokeAsync(() => cancelBtn.Click());
await _exporter.DidNotReceiveWithAnyArgs().ExportAsync(default!, default, default!, default);
}
/// <summary>Renders the MudDialogProvider so dialogs can be hosted in tests.</summary>
private sealed class DialogHost : Microsoft.AspNetCore.Components.ComponentBase
{
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
{
builder.OpenComponent<MudDialogProvider>(0);
builder.CloseComponent();
builder.OpenComponent<MudPopoverProvider>(1);
builder.CloseComponent();
builder.OpenComponent<MudSnackbarProvider>(2);
builder.CloseComponent();
}
}
}
@@ -0,0 +1,63 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Components;
public sealed class OddsCellTests : MarathonTestContext
{
[Fact]
public void Formats_decimal_to_two_places()
{
var cut = RenderComponent<OddsCell>(p => p.Add(c => c.Rate, 1.8m));
cut.Find(".m-odds__value").TextContent.Trim().Should().Be("1.80");
}
[Fact]
public void Renders_em_dash_when_rate_is_null()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, (decimal?)null);
p.Add(c => c.EmptyPlaceholder, "—");
});
cut.Find(".m-odds__value").TextContent.Trim().Should().Be("—");
}
[Fact]
public void Marks_up_when_rate_increased()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, 1.95m);
p.Add(c => c.Previous, 1.85m);
p.Add(c => c.ShowTrend, true);
});
cut.Find(".m-odds").GetAttribute("data-trend").Should().Be("rising");
cut.Find(".m-odds__delta").TextContent.Should().Contain("▲");
}
[Fact]
public void Marks_down_when_rate_decreased()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, 1.70m);
p.Add(c => c.Previous, 1.85m);
p.Add(c => c.ShowTrend, true);
});
cut.Find(".m-odds").GetAttribute("data-trend").Should().Be("falling");
cut.Find(".m-odds__delta").TextContent.Should().Contain("▼");
}
[Fact]
public void Hides_delta_glyph_when_show_trend_false()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, 1.85m);
p.Add(c => c.ShowTrend, false);
});
cut.FindAll(".m-odds__delta").Should().BeEmpty();
}
}
@@ -0,0 +1,37 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Components;
public sealed class SportIconTests : MarathonTestContext
{
[Theory]
[InlineData(6)]
[InlineData(11)]
[InlineData(22723)]
[InlineData(43658)]
[InlineData(99999)]
public void Renders_an_svg_for_known_and_unknown_sports(int code)
{
var cut = RenderComponent<SportIcon>(p =>
{
p.Add(c => c.Code, code);
p.Add(c => c.Label, $"sport-{code}");
});
cut.Find(".m-sport").GetAttribute("data-sport").Should().Be(code.ToString());
cut.FindAll("svg").Should().NotBeEmpty();
}
[Fact]
public void Sets_aria_label_from_label_param()
{
var cut = RenderComponent<SportIcon>(p =>
{
p.Add(c => c.Code, 11);
p.Add(c => c.Label, "Football");
});
cut.Find(".m-sport").GetAttribute("aria-label").Should().Be("Football");
}
}
@@ -0,0 +1,71 @@
using Bunit;
using Marathon.UI.Pages.Events;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Pages.Events;
public sealed class DetailTests : MarathonTestContext
{
[Fact]
public void Renders_not_found_when_detail_is_null()
{
Browsing.Detail = null;
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "missing"));
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("Detail.NotFound");
});
}
[Fact]
public void Renders_event_header_and_tabs_for_each_scope()
{
Browsing.Detail = TestData.Detail(id: "ABC-1");
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "ABC-1"));
cut.WaitForAssertion(() =>
{
// Two tabs from the seeded detail (Match + Period 1).
cut.FindAll(".m-detail-tab").Count.Should().Be(2);
cut.Markup.Should().Contain("Arsenal");
cut.Markup.Should().Contain("Chelsea");
cut.Markup.Should().Contain("Detail.Tabs.Match");
});
}
[Fact]
public void Renders_snapshot_history_entries()
{
Browsing.Detail = TestData.Detail(
id: "ABC-2",
new OddsTimelinePoint(DateTimeOffset.UtcNow.AddMinutes(-30), 1.90m, 3.30m, 4.10m),
new OddsTimelinePoint(DateTimeOffset.UtcNow.AddMinutes(-15), 1.85m, 3.40m, 4.20m),
new OddsTimelinePoint(DateTimeOffset.UtcNow, 1.80m, 3.50m, 4.30m));
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "ABC-2"));
cut.WaitForAssertion(() =>
{
// Three rows in history table (one per timeline point).
// We match the formatted rate cell text instead of generic table rows.
cut.Markup.Should().Contain("1.85");
cut.Markup.Should().Contain("1.80");
cut.Markup.Should().Contain("1.90");
});
}
[Fact]
public void Renders_chart_or_data_table_fallback()
{
Browsing.Detail = TestData.Detail(id: "ABC-3");
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "ABC-3"));
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("Detail.Chart.Title");
// <details> fallback for screen readers
cut.Markup.Should().Contain("Detail.Chart.AccessibleSummary");
});
}
}
@@ -0,0 +1,53 @@
using Bunit;
using Marathon.UI.Pages;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Marathon.UI.Tests.Pages;
public sealed class LiveTests : MarathonTestContext
{
public LiveTests()
{
var monitor = new TestOptionsMonitor<ScrapingSettingsForm>(new ScrapingSettingsForm
{
PollingIntervalSeconds = 30,
});
Services.AddSingleton<IOptionsMonitor<ScrapingSettingsForm>>(monitor);
}
[Fact]
public void Reads_polling_interval_from_options_monitor()
{
Browsing.LiveItems.Add(TestData.ListItem(id: "LV-1"));
var cut = RenderComponent<Live>();
cut.WaitForAssertion(() =>
{
// Live.AutoRefresh badge surfaces the configured cadence.
cut.Markup.Should().Contain("Live.AutoRefresh");
cut.Markup.Should().Contain("30 s");
});
}
[Fact]
public void Renders_seeded_live_rows()
{
Browsing.LiveItems.AddRange(new[]
{
TestData.ListItem(id: "LV-1", side1: "Roma", side2: "Lazio"),
TestData.ListItem(id: "LV-2", side1: "Federer", side2: "Nadal", sport: 22723),
});
var cut = RenderComponent<Live>();
cut.WaitForAssertion(() =>
{
cut.FindAll("[data-test=event-row]").Count.Should().Be(2);
});
cut.Markup.Should().Contain("Roma");
cut.Markup.Should().Contain("Federer");
}
}
@@ -0,0 +1,53 @@
using Bunit;
using Marathon.UI.Pages;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Pages;
public sealed class PreMatchTests : MarathonTestContext
{
[Fact]
public void Renders_seeded_rows()
{
Browsing.SportCodes.AddRange(new[] { 6, 11 });
Browsing.UpcomingItems.AddRange(new[]
{
TestData.ListItem(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
TestData.ListItem(id: "EV-2", side1: "Lakers", side2: "Bulls", sport: 6),
});
var cut = RenderComponent<PreMatch>();
cut.WaitForAssertion(() =>
{
var rows = cut.FindAll("[data-test=event-row]");
rows.Count.Should().Be(2);
});
cut.Markup.Should().Contain("Arsenal");
cut.Markup.Should().Contain("Lakers");
}
[Fact]
public void Renders_empty_state_when_no_events()
{
var cut = RenderComponent<PreMatch>();
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("PreMatch.Empty");
});
}
[Fact]
public void Calls_loader_with_active_filter_dates()
{
Browsing.UpcomingItems.Add(TestData.ListItem());
var cut = RenderComponent<PreMatch>();
cut.WaitForAssertion(() =>
{
Browsing.UpcomingCallCount.Should().BeGreaterThan(0);
Browsing.LastUpcomingFilter.Should().NotBeNull();
Browsing.LastUpcomingFilter!.Dates.From.Should().BeBefore(Browsing.LastUpcomingFilter.Dates.To);
});
}
}
@@ -0,0 +1,51 @@
using Marathon.UI.Services;
namespace Marathon.UI.Tests.Services;
public sealed class EventBrowsingStateTests
{
[Fact]
public void Default_filter_spans_minus_one_to_plus_seven_days()
{
var state = new EventBrowsingState();
var f = state.PreMatch;
(f.To - f.From).TotalDays.Should().BeApproximately(8, 0.01);
f.SortKey.Should().Be(EventSortKey.ScheduledAt);
f.SortDescending.Should().BeFalse();
}
[Fact]
public void UpdatePreMatch_raises_OnChange_when_value_changes()
{
var state = new EventBrowsingState();
var fired = 0;
state.OnChange += () => fired++;
var next = state.PreMatch with { SearchTerm = "Real Madrid" };
state.UpdatePreMatch(next);
fired.Should().Be(1);
state.PreMatch.SearchTerm.Should().Be("Real Madrid");
}
[Fact]
public void UpdatePreMatch_does_not_raise_when_value_unchanged()
{
var state = new EventBrowsingState();
var fired = 0;
state.OnChange += () => fired++;
state.UpdatePreMatch(state.PreMatch with { });
fired.Should().Be(0);
}
[Fact]
public void Live_and_PreMatch_filters_are_independent()
{
var state = new EventBrowsingState();
state.UpdateLive(state.Live with { SearchTerm = "live-only" });
state.PreMatch.SearchTerm.Should().BeEmpty();
state.Live.SearchTerm.Should().Be("live-only");
}
}
@@ -0,0 +1,62 @@
using Marathon.UI.Services;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Tests.Support;
/// <summary>
/// In-memory <see cref="IEventBrowsingService"/> for bUnit tests.
/// Seed via <see cref="UpcomingItems"/> / <see cref="LiveItems"/> / <see cref="Detail"/>.
/// </summary>
public sealed class FakeEventBrowsingService : IEventBrowsingService
{
public List<EventListItem> UpcomingItems { get; } = new();
public List<EventListItem> LiveItems { get; } = new();
public EventDetail? Detail { get; set; }
public List<int> SportCodes { get; } = new();
public List<string> CountryCodes { get; } = new();
public int UpcomingCallCount { get; private set; }
public int LiveCallCount { get; private set; }
public EventFilter? LastUpcomingFilter { get; private set; }
public EventFilter? LastLiveFilter { get; private set; }
public Task<IReadOnlyList<EventListItem>> ListUpcomingAsync(EventFilter filter, CancellationToken ct)
{
UpcomingCallCount++;
LastUpcomingFilter = filter;
return Task.FromResult<IReadOnlyList<EventListItem>>(Apply(UpcomingItems, filter));
}
public Task<IReadOnlyList<EventListItem>> ListLiveAsync(EventFilter filter, CancellationToken ct)
{
LiveCallCount++;
LastLiveFilter = filter;
return Task.FromResult<IReadOnlyList<EventListItem>>(Apply(LiveItems, filter));
}
public Task<EventDetail?> GetDetailAsync(DomainEventId eventId, CancellationToken ct)
=> Task.FromResult(Detail);
public Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<int>>(SportCodes);
public Task<IReadOnlyList<string>> ListKnownCountryCodesAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(CountryCodes);
private static IReadOnlyList<EventListItem> Apply(IEnumerable<EventListItem> source, EventFilter filter)
{
IEnumerable<EventListItem> q = source;
if (filter.SportCodes is { Count: > 0 } sports)
q = q.Where(e => sports.Contains(e.Sport.Value));
if (filter.CountryCodes is { Count: > 0 } countries)
q = q.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
{
var t = filter.SearchTerm.Trim();
q = q.Where(e =>
e.LeagueId.Contains(t, StringComparison.OrdinalIgnoreCase) ||
e.Side1Name.Contains(t, StringComparison.OrdinalIgnoreCase) ||
e.Side2Name.Contains(t, StringComparison.OrdinalIgnoreCase));
}
return q.ToList();
}
}
@@ -19,6 +19,8 @@ public abstract class MarathonTestContext : TestContext
protected TestSettingsWriter Writer { get; } = new();
protected ThemeState Theme { get; } = new();
protected LocaleState Locale { get; } = new();
protected EventBrowsingState BrowsingState { get; } = new();
protected FakeEventBrowsingService Browsing { get; } = new();
protected MarathonTestContext()
{
@@ -26,6 +28,8 @@ public abstract class MarathonTestContext : TestContext
Services.AddSingleton<ISettingsWriter>(Writer);
Services.AddSingleton(Theme);
Services.AddSingleton(Locale);
Services.AddSingleton(BrowsingState);
Services.AddSingleton<IEventBrowsingService>(Browsing);
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
@@ -0,0 +1,91 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.UI.Services;
namespace Marathon.UI.Tests.Support;
internal static class TestData
{
public static readonly TimeSpan Moscow = TimeSpan.FromHours(3);
public static EventListItem ListItem(
string id = "100001",
int sport = 11,
string country = "ENG",
string league = "Premier League",
string side1 = "Arsenal",
string side2 = "Chelsea",
decimal? win1 = 1.85m,
decimal? draw = 3.40m,
decimal? win2 = 4.20m,
DateTimeOffset? scheduled = null)
=> new(
new EventId(id),
new SportCode(sport),
country,
league,
side1,
side2,
scheduled ?? MoscowToday(20),
win1,
draw,
win2,
DateTimeOffset.UtcNow,
OddsSource.PreMatch);
/// <summary>Today at <paramref name="hour"/>:00 in Moscow (+03:00).</summary>
public static DateTimeOffset MoscowToday(int hour)
{
var nowMoscow = DateTimeOffset.UtcNow.ToOffset(Moscow);
var midnight = new DateTimeOffset(
nowMoscow.Year, nowMoscow.Month, nowMoscow.Day,
0, 0, 0, Moscow);
return midnight.AddHours(hour);
}
public static EventDetail Detail(
string id = "100001",
params OddsTimelinePoint[] timeline)
{
var moscow = Moscow;
var scheduled = MoscowToday(20);
var timelinePts = timeline.Length > 0
? timeline.ToList()
: new List<OddsTimelinePoint>
{
new(scheduled.AddMinutes(-30), 1.90m, 3.30m, 4.10m),
new(scheduled.AddMinutes(-15), 1.85m, 3.40m, 4.20m),
};
var matchBoard = new EventScopeBoard(MatchScope.Instance, new List<BetRow>
{
new(BetType.Win, Side.Side1, null, 1.85m),
new(BetType.Draw, Side.Draw, null, 3.40m),
new(BetType.Win, Side.Side2, null, 4.20m),
});
var period1Board = new EventScopeBoard(new PeriodScope(1), new List<BetRow>
{
new(BetType.Win, Side.Side1, null, 2.10m),
new(BetType.Win, Side.Side2, null, 3.10m),
new(BetType.Total, Side.More, 1.5m, 1.95m),
new(BetType.Total, Side.Less, 1.5m, 1.95m),
});
var history = timelinePts.Select(p =>
new SnapshotHistoryEntry(p.At, OddsSource.PreMatch, 8, p.Win1Rate, p.DrawRate, p.Win2Rate)
).ToList();
return new EventDetail(
new EventId(id),
new SportCode(11),
"ENG",
"Premier League",
"Arsenal",
"Chelsea",
scheduled,
new[] { matchBoard, period1Board },
timelinePts,
history);
}
}
@@ -0,0 +1,41 @@
using Microsoft.Extensions.Options;
namespace Marathon.UI.Tests.Support;
/// <summary>
/// Minimal in-memory <see cref="IOptionsMonitor{TOptions}"/> for tests.
/// </summary>
public sealed class TestOptionsMonitor<T> : IOptionsMonitor<T> where T : class, new()
{
private T _value;
private readonly List<Action<T, string?>> _listeners = new();
public TestOptionsMonitor(T initial)
{
_value = initial;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable OnChange(Action<T, string?> listener)
{
_listeners.Add(listener);
return new Subscription(() => _listeners.Remove(listener));
}
public void Set(T next)
{
_value = next;
foreach (var listener in _listeners.ToList())
listener(next, null);
}
private sealed class Subscription : IDisposable
{
private readonly Action _dispose;
public Subscription(Action dispose) { _dispose = dispose; }
public void Dispose() => _dispose();
}
}