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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>";
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.##") : "—";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user