diff --git a/CLAUDE.md b/CLAUDE.md index b346d7f..1219e33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,18 @@ Marathon__to_.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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 8342b11..18d41f2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,6 +55,7 @@ + diff --git a/plans/initial-implementation/CONTEXT.md b/plans/initial-implementation/CONTEXT.md index c55adb6..1db1c23 100644 --- a/plans/initial-implementation/CONTEXT.md +++ b/plans/initial-implementation/CONTEXT.md @@ -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`.** + 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` diff --git a/plans/initial-implementation/PLAN.md b/plans/initial-implementation/PLAN.md index 086ff6d..e00f81e 100644 --- a/plans/initial-implementation/PLAN.md +++ b/plans/initial-implementation/PLAN.md @@ -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 | ⬜ | ⬜ | ⬜ | diff --git a/plans/initial-implementation/phase-6-event-browsing-ui.md b/plans/initial-implementation/phase-6-event-browsing-ui.md index d0f5c10..57f6d85 100644 --- a/plans/initial-implementation/phase-6-event-browsing-ui.md +++ b/plans/initial-implementation/phase-6-event-browsing-ui.md @@ -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`; 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 `
` 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 + `
` 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; `` is overflow-x scrollable +- [x] Accessibility: + - Tables use `` / `
`; 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 `
`/`` + 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 — `
` 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 - +### 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, `
` 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 `.`): + +- `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` wraps `IOptionsMonitor` for tests that + need to drive options-change callbacks deterministically. diff --git a/src/Marathon.UI/Components/ExportDialog.razor b/src/Marathon.UI/Components/ExportDialog.razor new file mode 100644 index 0000000..357b39b --- /dev/null +++ b/src/Marathon.UI/Components/ExportDialog.razor @@ -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 L +@inject ExportToExcelUseCase ExportUseCase +@inject ILogger Logger + + + + @L["Export.Kicker"] +

+ @L["Export.Title"] +

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + @L["Export.Kind.PreMatch"] + @L["Export.Kind.Live"] + @L["Export.Kind.Combined"] + +
+
+ + @if (_error is not null) + { + + } +
+
+ + @L["Export.Cancel"] + + @if (_busy) + { + + @L["Common.Loading"] + } + else + { + @L["Export.Submit"] + } + + +
+ + + +@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(); + } + } +} diff --git a/src/Marathon.UI/Components/OddsCell.razor b/src/Marathon.UI/Components/OddsCell.razor new file mode 100644 index 0000000..74561c0 --- /dev/null +++ b/src/Marathon.UI/Components/OddsCell.razor @@ -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", + }; +} + + + @Formatted + @if (ShowTrend) + { + + } + + + + +@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 } +} diff --git a/src/Marathon.UI/Components/OddsTimeline.razor b/src/Marathon.UI/Components/OddsTimeline.razor new file mode 100644 index 0000000..988df3d --- /dev/null +++ b/src/Marathon.UI/Components/OddsTimeline.razor @@ -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 L + +
+ @if (HasData) + { + + +
+ @L["Detail.Chart.AccessibleSummary"] + + + + + + + + + + + + @foreach (var p in Points) + { + + + + + + + } + +
@L["Detail.Chart.Title"]
@L["Detail.Chart.Time"]@L["Detail.Chart.Win1"]@L["Detail.Chart.Draw"]@L["Detail.Chart.Win2"]
@p.At.ToString("HH:mm:ss") @FormatRate(p.Win1Rate)@FormatRate(p.DrawRate)@FormatRate(p.Win2Rate)
+
+ } + else + { +
+ + @L["Detail.Chart.Title"] + +

+ @L["Detail.Chart.Empty"] +

+
+ } +
+ + + +@code { + [Parameter, EditorRequired] public IReadOnlyList Points { get; set; } = Array.Empty(); + [Parameter] public bool DarkMode { get; set; } + + private PlotlyChart? _chart; + private Config _config = new() { Responsive = true, DisplayLogo = false }; + private Layout _layout = new(); + private IList _data = new List(); + 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 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 BuildTraces(IReadOnlyList points) + { + var times = points.Select(p => (object)p.At.UtcDateTime).ToList(); + return new List + { + 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 y, IList x) + { + // Plotly's IList 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 + { + 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 + { + 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 + { + 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") : "—"; +} diff --git a/src/Marathon.UI/Components/SportIcon.razor b/src/Marathon.UI/Components/SportIcon.razor new file mode 100644 index 0000000..aa71e21 --- /dev/null +++ b/src/Marathon.UI/Components/SportIcon.razor @@ -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. +*@ + + + @SvgContent + + + + +@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 = + "" + + "" + + "" + + ""; + + private const string Football = + "" + + "" + + "" + + ""; + + private const string Tennis = + "" + + "" + + "" + + "" + + ""; + + private const string Hockey = + "" + + "" + + "" + + "" + + ""; + + private const string Generic = + "" + + "" + + "" + + ""; +} diff --git a/src/Marathon.UI/Marathon.UI.csproj b/src/Marathon.UI/Marathon.UI.csproj index 4ca3105..59c68f9 100644 --- a/src/Marathon.UI/Marathon.UI.csproj +++ b/src/Marathon.UI/Marathon.UI.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Marathon.UI/Pages/Events/Detail.razor b/src/Marathon.UI/Pages/Events/Detail.razor new file mode 100644 index 0000000..292e9aa --- /dev/null +++ b/src/Marathon.UI/Pages/Events/Detail.razor @@ -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 L +@inject IEventBrowsingService Browsing +@inject IDialogService Dialog +@inject ISnackbar Snackbar +@inject ThemeState ThemeState +@inject NavigationManager Nav + +@L["App.Title"] · @L["Detail.Title"] + +
+ @if (_loading && _detail is null) + { +
+ + @L["Common.Loading"] +
+ } + else if (_detail is null) + { +
+ 404 +

@L["Detail.NotFound"]

+ + @L["Detail.BackToList"] + +
+ } + else + { +
+
+ @SportLabel(_detail.Sport.Value) · @_detail.CountryCode · @_detail.LeagueId +

+ @_detail.Side1Name vs @_detail.Side2Name +

+
+ @_detail.ScheduledAt.ToString("dd MMM yyyy · HH:mm") · MSK +
+
+
+
+ @L["Detail.Chart.Win1"] + +
+
+ @L["Detail.Chart.Draw"] + +
+
+ @L["Detail.Chart.Win2"] + +
+ + @L["Detail.Export"] + +
+
+ +
+ +
+ @foreach (var board in _detail.Boards) + { + var key = ScopeKey(board.Scope); + var label = ScopeLabel(board.Scope); + + } +
+ +
+
+ @if (ActiveBoard is { } active) + { +

+ @ScopeLabel(active.Scope) +

+ + + + + + + + + + + @foreach (var row in active.Bets) + { + + + + + + + } + +
@L["Detail.BetType"]@L["Detail.Side"]@L["Detail.Threshold"]@L["Detail.Rate"]
@BetTypeLabel(row.Type)@SideLabel(row.Side)@FormatThreshold(row.Threshold)@row.Rate.ToString("0.00")
+ } + else + { +

@L["Detail.NoBoards"]

+ } +
+ + +
+ } +
+ + + +@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(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.##") : "—"; +} diff --git a/src/Marathon.UI/Pages/Live.razor b/src/Marathon.UI/Pages/Live.razor index 2f1db0c..57a3d67 100644 --- a/src/Marathon.UI/Pages/Live.razor +++ b/src/Marathon.UI/Pages/Live.razor @@ -1,5 +1,61 @@ @page "/live" +@using Marathon.UI.Pages.Shared +@implements IDisposable @inject IStringLocalizer L +@inject IEventBrowsingService Browsing +@inject EventBrowsingState BrowsingState +@inject NavigationManager Nav +@inject IOptionsMonitor ScrapingMonitor @L["App.Title"] · @L["Nav.Live"] - + + + +@code { + private IReadOnlyList _availableSports = Array.Empty(); + private IReadOnlyList _availableCountries = Array.Empty(); + 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> 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(); +} diff --git a/src/Marathon.UI/Pages/PreMatch.razor b/src/Marathon.UI/Pages/PreMatch.razor index 85d3289..478830c 100644 --- a/src/Marathon.UI/Pages/PreMatch.razor +++ b/src/Marathon.UI/Pages/PreMatch.razor @@ -1,5 +1,50 @@ @page "/prematch" +@using Marathon.UI.Pages.Shared @inject IStringLocalizer L +@inject IEventBrowsingService Browsing +@inject EventBrowsingState BrowsingState +@inject NavigationManager Nav @L["App.Title"] · @L["Nav.PreMatch"] - + + + +@code { + private IReadOnlyList _availableSports = Array.Empty(); + private IReadOnlyList _availableCountries = Array.Empty(); + 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> 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)}"); +} diff --git a/src/Marathon.UI/Pages/Shared/EventListShell.razor b/src/Marathon.UI/Pages/Shared/EventListShell.razor new file mode 100644 index 0000000..3454a66 --- /dev/null +++ b/src/Marathon.UI/Pages/Shared/EventListShell.razor @@ -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 L + +
+
+ @Surface +

@Title

+ @if (!string.IsNullOrEmpty(Lede)) + { +

@Lede

+ } +
+ + + +
+ @if (_loading && _rows.Count == 0) + { +
+ + @L["Common.Loading"] +
+ } + else if (_rows.Count == 0) + { +
+ @L["Common.Empty"] +

+ @L["PreMatch.Empty"] +

+
+ } + else + { + + + + + + + + + + + + + + + @foreach (var row in _rows) + { + var key = row.Id.Value; + var prev = _previousRates.TryGetValue(key, out var pr) ? pr : default; + + + + + + + + + + + } + +
+ @L["PreMatch.Column.Time"] + @SortGlyph(EventSortKey.ScheduledAt) + + @L["PreMatch.Column.Country"] + @SortGlyph(EventSortKey.Country) + + @L["PreMatch.Column.League"] + @SortGlyph(EventSortKey.League) + @L["PreMatch.Column.Match"]@L["Detail.Chart.Win1"]@L["Detail.Chart.Draw"]@L["Detail.Chart.Win2"]
+ + @row.ScheduledAt.ToString("dd MMM HH:mm")@row.CountryCode@row.LeagueId@row.Side1Name vs @row.Side2Name + + + + + +
+ + } +
+
+ + + +@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>> Loader { get; set; } = default!; + [Parameter, EditorRequired] public EventBrowsingState.PageFilter Filter { get; set; } = default!; + [Parameter] public EventCallback OnFilterChanged { get; set; } + [Parameter] public EventCallback OnRowClicked { get; set; } + [Parameter] public IReadOnlyList? AvailableSports { get; set; } + [Parameter] public IReadOnlyList? 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 _rows = new(); + private DateTimeOffset? _lastLoadedAt; + private bool _loading; + private System.Timers.Timer? _refreshTimer; + private readonly Dictionary _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(); + } + 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(); + } +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 032b79e..9354382 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -149,4 +149,80 @@ Anomaly Suspension flip Confidence + + + Pre-match schedule + Upcoming events with their latest pre-match Win-1 / Draw / Win-2 odds preview. Filter by sport, country, league, or team. + No events match the current filters. Loosen the date range or clear the chips above. + Filter toolbar + From + To + Sports + Countries + Search league or team + e.g. Real Madrid, NBA, Roland Garros… + Time + Country + League + Match + events + refreshed at + + Live odds feed + Currently-live events with the most recent live snapshot. The list refreshes on the configured polling cadence; rows pulse when their odds move. + Auto-refresh + + Event + This event could not be loaded — it may have been removed from the source feed. + Back to schedule + Export + Bet scope tabs + Match + Period {0} + No bets captured yet for this event. + Type + Side + Threshold + Rate + Win + Draw + Handicap + Total + 1 + 2 + X + Under + Over + Odds movement + No snapshots captured yet for this event. + Time + Win 1 + Draw + Win 2 + Show data table + Snapshot history + Source + Bets + LIVE + PRE + + Export + Export to Excel + From date + To date + Snapshot kind + Pre-match only + Live only + Combined + Export + Cancel + Export saved to {0} + Pick a start and end date. + End date must be on or after the start date. + Export failed. + + Basketball + Football + Tennis + Hockey diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 9b3ffbd..e1e9c25 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -162,4 +162,80 @@ Аномалия Разворот после заморозки Уверенность + + + Расписание до матча + Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде. + Под текущие фильтры не подпадает ни одно событие. Расширьте диапазон или снимите чипы выше. + Панель фильтров + С + По + Виды спорта + Страны + Поиск по лиге или команде + напр. Реал Мадрид, NBA, Ролан Гаррос… + Время + Страна + Лига + Матч + событий + обновлено в + + Лайв-поток коэффициентов + Текущие лайв-события с последним сделанным снимком. Список обновляется по настроенному интервалу опроса; строки пульсируют при движении котировки. + Автообновление + + Событие + Событие не найдено — возможно, оно убрано из исходного потока. + К расписанию + Экспорт + Вкладки разделов ставок + Матч + Период {0} + Снимков ставок по этому событию ещё нет. + Тип + Сторона + Порог + Кэф + Победа + Ничья + Фора + Тотал + 1 + 2 + X + Меньше + Больше + Динамика коэффициентов + Снимков по этому событию ещё нет. + Время + П1 + X + П2 + Показать таблицу значений + История снимков + Источник + Ставок + ЛАЙВ + ДО МАТЧА + + Экспорт + Экспорт в Excel + Дата начала + Дата конца + Тип снимков + Только до матча + Только лайв + Комбинированный + Экспорт + Отмена + Файл сохранён в {0} + Выберите даты начала и конца. + Дата конца должна быть не раньше даты начала. + Экспорт не удался. + + Баскетбол + Футбол + Теннис + Хоккей diff --git a/src/Marathon.UI/Services/EventBrowsingService.cs b/src/Marathon.UI/Services/EventBrowsingService.cs new file mode 100644 index 0000000..0780902 --- /dev/null +++ b/src/Marathon.UI/Services/EventBrowsingService.cs @@ -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; + +/// +/// 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. +/// +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> ListUpcomingAsync(EventFilter filter, CancellationToken ct) + => await BuildListAsync(filter, OddsSource.PreMatch, ct).ConfigureAwait(false); + + public async Task> ListLiveAsync(EventFilter filter, CancellationToken ct) + => await BuildListAsync(filter, OddsSource.Live, ct).ConfigureAwait(false); + + public async Task 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() + : 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> 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> 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> 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 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(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 ApplySort(IEnumerable 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 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(); + 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 + { + 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, + }; + } +} diff --git a/src/Marathon.UI/Services/EventBrowsingState.cs b/src/Marathon.UI/Services/EventBrowsingState.cs new file mode 100644 index 0000000..f3f6b00 --- /dev/null +++ b/src/Marathon.UI/Services/EventBrowsingState.cs @@ -0,0 +1,91 @@ +namespace Marathon.UI.Services; + +/// +/// 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. +/// +/// +/// 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. +/// +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(); + } + + /// + /// Persistent (per-page) filter values that survive navigation within + /// the same circuit. Immutable record — pages produce new instances. + /// + public sealed record PageFilter( + DateTimeOffset From, + DateTimeOffset To, + IReadOnlyList SportCodes, + IReadOnlyList 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(), + CountryCodes: Array.Empty(), + 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); + } +} diff --git a/src/Marathon.UI/Services/EventViewModels.cs b/src/Marathon.UI/Services/EventViewModels.cs new file mode 100644 index 0000000..39e090f --- /dev/null +++ b/src/Marathon.UI/Services/EventViewModels.cs @@ -0,0 +1,102 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.UI.Services; + +/// +/// 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. +/// +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); + +/// +/// Snapshot of the bet board for a single event scope (Match or PeriodN). +/// +public sealed record EventScopeBoard( + BetScope Scope, + IReadOnlyList Bets); + +/// +/// Single row in the per-scope bet board. +/// +public sealed record BetRow( + BetType Type, + Side Side, + decimal? Threshold, + decimal Rate); + +/// +/// Full event detail aggregate for the detail page. +/// +public sealed record EventDetail( + EventId Id, + SportCode Sport, + string CountryCode, + string LeagueId, + string Side1Name, + string Side2Name, + DateTimeOffset ScheduledAt, + IReadOnlyList Boards, + IReadOnlyList Timeline, + IReadOnlyList History); + +/// +/// Single point in the odds-over-time chart — Win-1/Draw/Win-2 rates at a moment. +/// +public sealed record OddsTimelinePoint( + DateTimeOffset At, + decimal? Win1Rate, + decimal? DrawRate, + decimal? Win2Rate); + +/// +/// Single row in the snapshot history table on the detail page. +/// +public sealed record SnapshotHistoryEntry( + DateTimeOffset CapturedAt, + OddsSource Source, + int BetCount, + decimal? Win1Rate, + decimal? DrawRate, + decimal? Win2Rate); + +/// +/// Filter state passed from the page to the service. All members optional — +/// a request with everything null returns an unfiltered slice. +/// +public sealed record EventFilter( + DateRangeFilter Dates, + IReadOnlyCollection? SportCodes = null, + IReadOnlyCollection? CountryCodes = null, + string? SearchTerm = null, + EventSortKey SortKey = EventSortKey.ScheduledAt, + bool SortDescending = false, + OddsSource? Source = null); + +/// +/// Inclusive [From..To] date filter; collapses to a 7-day window when +/// both members are null. +/// +public sealed record DateRangeFilter(DateTimeOffset From, DateTimeOffset To); + +/// The columns the user can sort by on the list pages. +public enum EventSortKey +{ + ScheduledAt, + Sport, + Country, + League, +} diff --git a/src/Marathon.UI/Services/IEventBrowsingService.cs b/src/Marathon.UI/Services/IEventBrowsingService.cs new file mode 100644 index 0000000..f036adb --- /dev/null +++ b/src/Marathon.UI/Services/IEventBrowsingService.cs @@ -0,0 +1,27 @@ +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.UI.Services; + +/// +/// Read-only browsing facade over the Event/Snapshot repositories. +/// Pages depend on this — never on IEventRepository directly — so +/// view-model shaping stays in one place and the UI does not bind to mutable +/// EF graphs. +/// +public interface IEventBrowsingService +{ + /// List upcoming (pre-match) events matching the filter. + Task> ListUpcomingAsync(EventFilter filter, CancellationToken ct); + + /// List currently-live events with their most recent live snapshot odds. + Task> ListLiveAsync(EventFilter filter, CancellationToken ct); + + /// Full detail aggregate for the event detail page; null when the event is not found. + Task GetDetailAsync(DomainEventId eventId, CancellationToken ct); + + /// The set of distinct sport codes present in the data — used to populate the filter chips. + Task> ListKnownSportCodesAsync(CancellationToken ct); + + /// The set of distinct country codes present in the data — used to populate the filter chips. + Task> ListKnownCountryCodesAsync(CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index 0c69506..c8a6525 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -45,6 +45,10 @@ public static class UiServicesExtensions // Singletons that drive UI chrome state. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + // Browsing facade — Scoped so it captures the per-circuit repository scope. + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.UI.Tests/Components/ExportDialogTests.cs b/tests/Marathon.UI.Tests/Components/ExportDialogTests.cs new file mode 100644 index 0000000..569ee9b --- /dev/null +++ b/tests/Marathon.UI.Tests/Components/ExportDialogTests.cs @@ -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(); + + 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.Instance)); + } + + [Fact] + public async Task Submit_calls_export_use_case() + { + _exporter.ExportAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("C:/temp/Marathon_2026-05-04_to_2026-05-06.xlsx")); + + var host = RenderComponent(); + IDialogReference? reference = null; + await host.InvokeAsync(async () => + { + var svc = Services.GetRequiredService(); + reference = await svc.ShowAsync("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(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Cancel_does_not_call_export() + { + var host = RenderComponent(); + await host.InvokeAsync(async () => + { + var svc = Services.GetRequiredService(); + await svc.ShowAsync("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); + } + + /// Renders the MudDialogProvider so dialogs can be hosted in tests. + private sealed class DialogHost : Microsoft.AspNetCore.Components.ComponentBase + { + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.CloseComponent(); + builder.OpenComponent(1); + builder.CloseComponent(); + builder.OpenComponent(2); + builder.CloseComponent(); + } + } +} diff --git a/tests/Marathon.UI.Tests/Components/OddsCellTests.cs b/tests/Marathon.UI.Tests/Components/OddsCellTests.cs new file mode 100644 index 0000000..7f59bf1 --- /dev/null +++ b/tests/Marathon.UI.Tests/Components/OddsCellTests.cs @@ -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(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(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(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(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(p => + { + p.Add(c => c.Rate, 1.85m); + p.Add(c => c.ShowTrend, false); + }); + cut.FindAll(".m-odds__delta").Should().BeEmpty(); + } +} diff --git a/tests/Marathon.UI.Tests/Components/SportIconTests.cs b/tests/Marathon.UI.Tests/Components/SportIconTests.cs new file mode 100644 index 0000000..361cb89 --- /dev/null +++ b/tests/Marathon.UI.Tests/Components/SportIconTests.cs @@ -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(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(p => + { + p.Add(c => c.Code, 11); + p.Add(c => c.Label, "Football"); + }); + cut.Find(".m-sport").GetAttribute("aria-label").Should().Be("Football"); + } +} diff --git a/tests/Marathon.UI.Tests/Pages/Events/DetailTests.cs b/tests/Marathon.UI.Tests/Pages/Events/DetailTests.cs new file mode 100644 index 0000000..3c7cef0 --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/Events/DetailTests.cs @@ -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(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(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(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(p => p.Add(d => d.EventCode, "ABC-3")); + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("Detail.Chart.Title"); + //
fallback for screen readers + cut.Markup.Should().Contain("Detail.Chart.AccessibleSummary"); + }); + } +} diff --git a/tests/Marathon.UI.Tests/Pages/LiveTests.cs b/tests/Marathon.UI.Tests/Pages/LiveTests.cs new file mode 100644 index 0000000..5cfa244 --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/LiveTests.cs @@ -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(new ScrapingSettingsForm + { + PollingIntervalSeconds = 30, + }); + Services.AddSingleton>(monitor); + } + + [Fact] + public void Reads_polling_interval_from_options_monitor() + { + Browsing.LiveItems.Add(TestData.ListItem(id: "LV-1")); + + var cut = RenderComponent(); + + 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(); + cut.WaitForAssertion(() => + { + cut.FindAll("[data-test=event-row]").Count.Should().Be(2); + }); + cut.Markup.Should().Contain("Roma"); + cut.Markup.Should().Contain("Federer"); + } +} diff --git a/tests/Marathon.UI.Tests/Pages/PreMatchTests.cs b/tests/Marathon.UI.Tests/Pages/PreMatchTests.cs new file mode 100644 index 0000000..1752a90 --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/PreMatchTests.cs @@ -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(); + 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(); + 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(); + cut.WaitForAssertion(() => + { + Browsing.UpcomingCallCount.Should().BeGreaterThan(0); + Browsing.LastUpcomingFilter.Should().NotBeNull(); + Browsing.LastUpcomingFilter!.Dates.From.Should().BeBefore(Browsing.LastUpcomingFilter.Dates.To); + }); + } +} diff --git a/tests/Marathon.UI.Tests/Services/EventBrowsingStateTests.cs b/tests/Marathon.UI.Tests/Services/EventBrowsingStateTests.cs new file mode 100644 index 0000000..cd2b4a9 --- /dev/null +++ b/tests/Marathon.UI.Tests/Services/EventBrowsingStateTests.cs @@ -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"); + } +} diff --git a/tests/Marathon.UI.Tests/Support/FakeEventBrowsingService.cs b/tests/Marathon.UI.Tests/Support/FakeEventBrowsingService.cs new file mode 100644 index 0000000..b7a2c3b --- /dev/null +++ b/tests/Marathon.UI.Tests/Support/FakeEventBrowsingService.cs @@ -0,0 +1,62 @@ +using Marathon.UI.Services; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.UI.Tests.Support; + +/// +/// In-memory for bUnit tests. +/// Seed via / / . +/// +public sealed class FakeEventBrowsingService : IEventBrowsingService +{ + public List UpcomingItems { get; } = new(); + public List LiveItems { get; } = new(); + public EventDetail? Detail { get; set; } + public List SportCodes { get; } = new(); + public List 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> ListUpcomingAsync(EventFilter filter, CancellationToken ct) + { + UpcomingCallCount++; + LastUpcomingFilter = filter; + return Task.FromResult>(Apply(UpcomingItems, filter)); + } + + public Task> ListLiveAsync(EventFilter filter, CancellationToken ct) + { + LiveCallCount++; + LastLiveFilter = filter; + return Task.FromResult>(Apply(LiveItems, filter)); + } + + public Task GetDetailAsync(DomainEventId eventId, CancellationToken ct) + => Task.FromResult(Detail); + + public Task> ListKnownSportCodesAsync(CancellationToken ct) + => Task.FromResult>(SportCodes); + + public Task> ListKnownCountryCodesAsync(CancellationToken ct) + => Task.FromResult>(CountryCodes); + + private static IReadOnlyList Apply(IEnumerable source, EventFilter filter) + { + IEnumerable 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(); + } +} diff --git a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs index 268c898..066427a 100644 --- a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs +++ b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs @@ -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(Writer); Services.AddSingleton(Theme); Services.AddSingleton(Locale); + Services.AddSingleton(BrowsingState); + Services.AddSingleton(Browsing); Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>)); Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); diff --git a/tests/Marathon.UI.Tests/Support/TestData.cs b/tests/Marathon.UI.Tests/Support/TestData.cs new file mode 100644 index 0000000..86b9d7c --- /dev/null +++ b/tests/Marathon.UI.Tests/Support/TestData.cs @@ -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); + + /// Today at :00 in Moscow (+03:00). + 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 + { + 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 + { + 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 + { + 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); + } +} diff --git a/tests/Marathon.UI.Tests/Support/TestOptionsMonitor.cs b/tests/Marathon.UI.Tests/Support/TestOptionsMonitor.cs new file mode 100644 index 0000000..5241f4a --- /dev/null +++ b/tests/Marathon.UI.Tests/Support/TestOptionsMonitor.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Options; + +namespace Marathon.UI.Tests.Support; + +/// +/// Minimal in-memory for tests. +/// +public sealed class TestOptionsMonitor : IOptionsMonitor where T : class, new() +{ + private T _value; + private readonly List> _listeners = new(); + + public TestOptionsMonitor(T initial) + { + _value = initial; + } + + public T CurrentValue => _value; + + public T Get(string? name) => _value; + + public IDisposable OnChange(Action 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(); + } +}