feat(phase-7-frontend): anomaly feed UI + nav badge + Settings toggle (+31 bUnit tests)

Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.

New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
  Phase 5 placeholder with a severity-coded card stream, filter chips
  (severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
  lockup + AnomalyEvidence panel + back link to /events/{eventCode}.

New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
  amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
  pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
  handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
  bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
  signal-red 3px left border on the post column. Handles 2-way (no draw).

State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
  / AnomalyEvidenceSnapshot record. Severity computed in the view-model
  from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
  parses Anomaly.EvidenceJson into typed view-models, applies filters
  client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
  GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
  sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.

Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
  pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
  user clicks 'Mark all read' on the feed toolbar.

Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
  added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
  (default true).

Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.

Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.

Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.

Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).

Phase 7 status:  Done (backend + frontend both complete, awaiting review).

Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
This commit is contained in:
2026-05-05 13:39:39 +03:00
parent a6ff368015
commit 12208a4762
27 changed files with 2273 additions and 32 deletions
+21
View File
@@ -182,3 +182,24 @@ For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.)
`1st_Half`/`1st_Quarter`, tennis=`1st_Set`, hockey=`1st_Period`. Domain stores
`PeriodNumber:int` and a sport-aware `PeriodScopeMapper` resolves the correct
market token at parse time.
## Feature: Initial Implementation > Phase 7: Anomaly UI — Learnings
- **Severity buckets are a single-source rule** in
`Marathon.UI.Services.AnomalySeverityRules.FromScore` (Low <0.45, Medium <0.60,
High ≥0.60). The badge pill, card border, feed filter chip, and stat strip all
bind through it — never duplicate the thresholds.
- **`AnomalyBrowsingService` is Scoped, `AnomalyBrowsingState` is Singleton** —
same lifetime split as `EventBrowsingService` / `EventBrowsingState`. State
holds the immutable `AnomalyFilter` + `LastSeenUtc` + cached unread count.
- **`Anomaly.EvidenceJson` is parsed once in the service layer** via
`JsonSerializer.Deserialize<EvidenceDto>(json, PropertyNameCaseInsensitive=true)`
with private nested DTOs. Pages bind to `AnomalyEvidenceSnapshot` value-records
— they never see the raw JSON. Malformed JSON is dropped silently from the feed.
- **2-way markets (tennis) carry `pDraw=null` / `rateDraw=null`.** The
`AnomalyEvidence` and `AnomalyCard` components key off the `IsTwoWay` flag on
`AnomalyListItem` (computed when both pre/post `pDraw` are null) to omit the
Draw row in BOTH columns — never per-column-individually.
- **Signal-red is the load-bearing alert tone** for Phase 7. Use
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
(`m-pulse`) MUST respect `prefers-reduced-motion`.
+47 -1
View File
@@ -86,7 +86,7 @@ with scraping research, no implementation.
| 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) | — | ✅ 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 7 | phase-implementer (split + UI Opus 1M) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. Backend (Sonnet, a6ff368): pure `AnomalyDetector` + `DetectAnomaliesUseCase` + `AnomalyDetectionPoller` + 14 backend tests. Frontend (Opus 1M): `AnomalyFeed.razor` + `Detail.razor` + `AnomalyCard`/`SeverityBadge`/`AnomalyEvidence` components + `IAnomalyBrowsingService`/`AnomalyBrowsingService`/`AnomalyBrowsingState`/`AnomalyViewModels`. Nav badge with pulsing signal-red unread count. Settings page wired with `Workers:AnomalyDetectionEnabled`. 28 new `Anomaly.*` localization keys (RU+EN parity). 276/276 tests green (+31 new bUnit). |
| 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 |
@@ -139,6 +139,52 @@ with scraping research, no implementation.
- **Test note:** rates 1.5/2.5 produce a flip score of ~0.25 — BELOW the 0.30 threshold.
Always use 1.3/4.0 (flip score ~0.51) or steeper to guarantee detection in tests.
### Phase 7 Frontend (Anomaly UI, 2026-05-05)
- **Routing — Option A.** Removed the `Pages/Anomalies.razor` placeholder and added
`Pages/Anomalies/AnomalyFeed.razor` (`@page "/anomalies"`) plus
`Pages/Anomalies/Detail.razor` (`@page "/anomalies/{id:guid}"`). Mirrors the
`Pages/Events/Detail.razor` shape from Phase 6.
- **State + Service split mirrors Phase 6** — `AnomalyBrowsingState` (Singleton inside
the RCL; per-circuit in BlazorWebView), `IAnomalyBrowsingService`
`AnomalyBrowsingService` (Scoped). The service does NOT call back into the detector;
it reads `IAnomalyRepository.ListAsync` + `IEventRepository.GetAsync` (per distinct
EventId) and maps to immutable view-model records.
- **`EvidenceJson` parsing** uses `System.Text.Json.JsonSerializer.Deserialize` with
`PropertyNameCaseInsensitive = true` and private nested DTOs. Failures (malformed
JSON, missing pre/post snapshot) drop the row silently — the feed shows the rest.
- **Severity buckets** are defined once in `AnomalySeverityRules` (Low <0.45, Medium
<0.60, High ≥0.60) per the backend handoff. The UI reuses the same enum across
filter chips, the badge pill, and the card border.
- **Signal-red is load-bearing.** High-severity pills, card left borders, evidence
post-suspension column outline, the favourite-swap callout, and the nav badge all
bind to `--m-c-anomaly`. Medium severity uses the editorial amber `--m-c-accent`;
low severity uses the muted `--m-c-ink-soft`. No new color literals introduced.
- **`AnomalyEvidence` panel** renders two columns (pre → arrow → post). Each row
shows the side label, an implied-probability bar (favourite uses amber/red), and
the raw rate in tabular mono. 2-way markets (tennis) skip the Draw row in BOTH
columns based on the parsed `pDraw` being null. The panel highlights a
favourite-swap with a one-line callout above the columns.
- **Nav badge** lives in `NavBody.razor`, driven by `AnomalyBrowsingState.UnreadCount`.
The feed page calls `IAnomalyBrowsingService.GetUnreadCountAsync(LastSeenUtc)` after
each load and pushes the count into state. The user clears it via "Mark all read"
on the feed toolbar (writes `LastSeenUtc = UtcNow`). The badge pulses with
`m-pulse` and respects `prefers-reduced-motion`.
- **Settings page** — added the `Workers:AnomalyDetectionEnabled` toggle inside the
existing WORKERS section, mirroring `LivePollerEnabled` / `UpcomingPollerEnabled`.
Bound via `IOptionsMonitor<WorkerOptions>` already in scope.
- **`Marathon.UI.Services.WorkerOptions`** — added `AnomalyDetectionEnabled` mutable
field (set-able for the form-binding pattern used by the Settings page). The
Infrastructure-side `WorkerOptions` already had the flag.
- **Test infrastructure** — added `FakeAnomalyBrowsingService` with
`MakeItem(...)` / `MakeSnapshot(...)` static factories; registered in
`MarathonTestContext` alongside `AnomalyBrowsingState`.
- **Localization** — 28 new `Anomaly.*` keys (RU+EN parity) under the
`<Surface>.<Element>` convention from Phase 5/6, plus
`Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`.
- **New test count: +31** (9 SeverityBadge + 6 AnomalyCard + 6 AnomalyEvidence +
5 AnomalyFeed + 5 AnomalyDetail). Total: 276/276 passing.
### Phase 6 (Event browsing UI, 2026-05-05)
- **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes;
+2 -2
View File
@@ -41,7 +41,7 @@ parameter configurable.
- [x] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.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)
- [x] 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)
@@ -69,7 +69,7 @@ parameter configurable.
| 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 | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 228/228 tests | ✅ 553db2b |
| Phase 7: Anomaly detection | fullstack | 🔨 Backend done | ⬜ | ✅ Build OK + 245/245 tests | ⬜ |
| Phase 7: Anomaly detection | fullstack | ✅ Done | ⬜ | ✅ Build OK + 276/276 tests | ⬜ |
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 9: Packaging + polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
@@ -1,6 +1,6 @@
# Phase 7: Anomaly Detection (Suspension + Flip)
**Status:** 🔨 Backend Done — Awaiting Frontend (Opus)
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
**Implementer:** Sonnet (backend portion) + Opus (UI portion, with frontend-design)
@@ -70,26 +70,28 @@ and surface them in a dedicated UI feed page so the user can act on them.
- Tolerates per-event failures (one event throwing doesn't abort the cycle) ✓
- Returns count of new anomalies ✓
### Frontend (Opus + frontend-design) ⬜ NOT STARTED
### Frontend (Opus + frontend-design) ✅ COMPLETE
- [ ] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`:
- [x] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`:
- List of anomalies sorted by `DetectedAt` descending
- Each card shows: severity (color-coded by score), event identity, sport icon,
detected timestamp, mini sparkline of pre/post odds
- Click card → expand to show evidence timeline (snapshots before/after suspension)
- Filter: severity threshold, sport, date range
- [ ] Create `Marathon.UI/Components/AnomalyCard.razor` — visually distinctive,
attention-grabbing without being garish; follows frontend-design guidance for
information hierarchy.
- [ ] Add navigation entry to `MainLayout` drawer with notification badge showing
unread anomaly count.
- [ ] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs`
+ `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs`
- [ ] Append localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx`
- [ ] Add Settings UI binding for `AnomalyDetectionEnabled` worker flag (see handoff)
- [ ] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/`:
- bUnit: anomaly card renders evidence timeline
- bUnit: filter narrows the list correctly
detected timestamp, pre→post odds strip
- Click card → navigate to `/anomalies/{id}` detail page
- Filter: severity threshold (Low/Med/High chips), sport chips, date range
- [x] Create `Marathon.UI/Pages/Anomalies/Detail.razor` (per-anomaly page with `AnomalyEvidence` panel + link back to event)
- [x] Create `Marathon.UI/Components/AnomalyCard.razor` — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap.
- [x] Create `Marathon.UI/Components/SeverityBadge.razor` — pill: Low (neutral), Medium (amber), High (signal-red, pulsing).
- [x] Create `Marathon.UI/Components/AnomalyEvidence.razor` — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout.
- [x] Add navigation entry to `NavBody.razor` drawer with pulsing red badge showing unread anomaly count.
- [x] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs`
- [x] Append `Anomaly.*` localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` (28 keys, full RU/EN parity)
- [x] Add Settings UI binding for `Workers:AnomalyDetectionEnabled` worker flag
- [x] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/` + `Components/`:
- `SeverityBadgeTests` — score → severity bucket → pill class (9 tests)
- `AnomalyCardTests` — severity styling, click callback, 2-way vs 3-way (6 tests)
- `AnomalyEvidenceTests` — two-column render, favourite-swap callout, 2-way row count, suspension duration formatting (6 tests)
- `AnomalyFeedTests` — seeded list render, empty state, severity/sport chip filtering, mark-read state mutation (5 tests)
- `AnomalyDetailTests` — not-found fallback, evidence + back-link rendering, suspension duration in header (4 tests)
## Files to Modify/Create
@@ -123,9 +125,9 @@ and surface them in a dedicated UI feed page so the user can act on them.
- [x] Compiles (Big Bang).
- [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies.
- [x] Configurable thresholds via `appsettings.json`.
- [ ] Visible in Settings page (UI agent must wire `AnomalyDetectionEnabled`).
- [ ] UI clearly distinguishes high/medium/low severity anomalies.
- [ ] Evidence timeline shows the actual snapshots that triggered the detection.
- [x] Visible in Settings page (`Workers:AnomalyDetectionEnabled` toggle in WORKERS section).
- [x] UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card).
- [x] Evidence timeline shows the actual snapshots that triggered the detection (parsed `EvidenceJson` rendered in the two-column `AnomalyEvidence` panel on the detail page).
## Notes
@@ -139,8 +141,8 @@ and surface them in a dedicated UI feed page so the user can act on them.
- [x] Detector is deterministic and pure
- [x] Score calculation correct (verified against hand-computed example in test comments)
- [x] No false positives on synthetic "normal" timelines
- [ ] UI evidence timeline matches stored `EvidenceJson`
- [ ] All strings localized
- [x] UI evidence timeline matches stored `EvidenceJson` (`AnomalyBrowsingService` parses the JSON via System.Text.Json and `AnomalyEvidence` renders both bracket snapshots verbatim — no synthesised data).
- [x] All strings localized (RU + EN parity for the 28 new `Anomaly.*` + 2 new `Settings.Workers.AnomalyDetectionEnabled*` keys).
## Handoff to Next Phase
@@ -277,3 +279,41 @@ documented in CONTEXT.md Phase 6 notes. Specifically:
- **No read API for "unread anomaly count"** — the nav badge will need to read from
the full list and maintain a "last seen" timestamp in `AnomalyBrowsingState`.
Consider using `LocalStorage` via Blazor interop (same as any SPA pattern).
---
### Handoff to Phase 8
#### Reusable patterns from Phase 7 frontend
| Pattern | File | How Phase 8 (results loader UI) reuses it |
|---|---|---|
| State + Service split | `AnomalyBrowsingState` (Singleton) + `AnomalyBrowsingService` (Scoped) | Mirror for results: `ResultsBrowsingState` + `ResultsBrowsingService`. Pages never inject `IResultRepository` directly. |
| View-model factory | `AnomalyViewModels.cs` (`AnomalyListItem`, `AnomalyDetailVm`, `AnomalyEvidenceSnapshot`) | Phase 8 should expose `ResultListItem` / `ResultDetail` records — keep the UI shielded from EF graphs. |
| Severity-style chips | `AnomalyFeed.razor` toolbar (`m-chip` w/ `aria-pressed`) | Match the chip cadence for results filters (sport, status: pending/complete). |
| Evidence panel | `AnomalyEvidence.razor` two-column layout | If results show "predicted vs final" deltas, reuse the same paired-card structure. |
| Severity-coded card | `AnomalyCard.razor` left-border colour driven by severity | Pattern transfers to "result outcome" badging if needed (winner/loser/draw). |
| Nav badge | `NavBody.razor` `m-nav__badge` (signal-red, pulsing) | Phase 8 may want a similar "new results" badge. CSS class is already factored. |
#### New CSS surfaces introduced
- `.m-severity` / `.m-severity--{low,medium,high}` — small pill, severity-coded.
- `.m-anomaly-card` / `.m-anomaly-card--{low,medium,high}` — feed card with severity-coded left border.
- `.m-evidence` / `.m-evidence__col` / `.m-evidence__bar` — two-column evidence panel.
- `.m-anomaly-feed__stats` — at-a-glance count strip (Total / High / Medium / Low).
- `.m-nav__badge` — signal-red pulsing pill on the drawer link.
#### Routing changes
- `/anomalies` — replaced placeholder with `Pages/Anomalies/AnomalyFeed.razor`.
- `/anomalies/{id:guid}` — new detail page `Pages/Anomalies/Detail.razor`.
- The `Pages/Anomalies.razor` placeholder file was deleted (Option A from the brief).
#### Test infrastructure
- `tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs` — in-memory fake with `MakeItem(...)` and `MakeSnapshot(...)` factory helpers.
- `MarathonTestContext` now also registers `AnomalyBrowsingState` (singleton) + the fake. Phase 8 tests can follow the same factory pattern for `IResultBrowsingService`.
#### Localization keys added
28 `Anomaly.*` keys (RU+EN full parity) plus `Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`. All under the `<Surface>.<Element>` convention from Phase 5/6.
@@ -0,0 +1,249 @@
@*
AnomalyCard — single row in the anomaly feed.
Asymmetric layout: severity badge at top-right, sport icon + event title
at top-left, a compact pre→post odds strip in the middle, the detected-at
timestamp at the bottom. Whole card is clickable / Enter/Space-keyable to
navigate to /anomalies/{id}.
Visual tone shifts with severity:
- High: signal-red left border + paper-2 background.
- Medium: amber left border.
- Low: muted neutral border.
*@
@using Marathon.UI.Components
@inject IStringLocalizer<SharedResource> L
<article class="m-anomaly-card m-anomaly-card--@_severityClass m-rise"
role="link"
tabindex="0"
data-test="anomaly-card"
data-anomaly-id="@Item.Id"
@onclick="HandleClick"
@onkeydown="HandleKey">
<header class="m-anomaly-card__head">
<div class="m-anomaly-card__lockup">
<SportIcon Code="@Item.Sport.Value" Label="@SportLabel(Item.Sport.Value)" ClassName="m-anomaly-card__sport" />
<div class="m-anomaly-card__title-block">
<span class="m-kicker">@KindLabel(Item.Kind) · @Item.CountryCode · @Item.LeagueId</span>
<h3 class="m-anomaly-card__title">@Item.EventTitle</h3>
</div>
</div>
<SeverityBadge Severity="Item.Severity" Score="Item.Score" />
</header>
<div class="m-anomaly-card__strip" aria-hidden="true">
@RenderRateCell(L["Detail.Chart.Win1"], Item.PreWin1Rate, Item.PostWin1Rate)
@if (!Item.IsTwoWay)
{
@RenderRateCell(L["Detail.Chart.Draw"], Item.PreDrawRate, Item.PostDrawRate)
}
@RenderRateCell(L["Detail.Chart.Win2"], Item.PreWin2Rate, Item.PostWin2Rate)
</div>
<footer class="m-anomaly-card__foot">
<span class="m-mono m-anomaly-card__time">
<span class="m-anomaly-card__time-label">@L["Anomaly.Card.DetectedAt"]</span>
<time datetime="@Item.DetectedAt.ToString("o")" title="@Item.DetectedAt.ToString("dd MMM yyyy HH:mm:ss")">
@FormatRelative(Item.DetectedAt)
</time>
</span>
<span class="m-mono m-anomaly-card__gap">
@L["Anomaly.Card.GapSeconds"] · @FormatGap(Item.SuspensionGapSeconds)
</span>
</footer>
</article>
<style>
.m-anomaly-card {
display: grid;
gap: var(--m-space-3);
padding: var(--m-space-4) var(--m-space-5);
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
text-decoration: none;
color: inherit;
}
.m-anomaly-card:hover {
background: var(--m-c-paper-2);
transform: translateX(2px);
}
.m-anomaly-card:focus-visible {
outline: 2px solid var(--m-c-accent);
outline-offset: 2px;
}
.m-anomaly-card--high { border-left-color: var(--m-c-anomaly); }
.m-anomaly-card--medium { border-left-color: var(--m-c-accent); }
.m-anomaly-card--low { border-left-color: var(--m-c-ink-soft); }
.m-anomaly-card__head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--m-space-3);
align-items: start;
}
.m-anomaly-card__lockup {
display: flex;
gap: var(--m-space-3);
align-items: flex-start;
}
.m-anomaly-card__sport {
--m-sport-size: 22px;
margin-top: 4px;
}
.m-anomaly-card__title-block { display: grid; gap: 4px; min-width: 0; }
.m-anomaly-card__title {
margin: 0;
font-family: var(--m-font-display);
font-weight: 400;
font-size: 1.125rem;
line-height: 1.25;
color: var(--m-c-ink);
overflow: hidden;
text-overflow: ellipsis;
}
.m-anomaly-card__strip {
display: flex;
gap: var(--m-space-3);
flex-wrap: wrap;
padding: var(--m-space-3);
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
}
.m-anomaly-card__rate {
display: grid;
grid-template-columns: auto auto auto auto;
gap: 8px;
align-items: baseline;
font-family: var(--m-font-mono);
}
.m-anomaly-card__rate-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
}
.m-anomaly-card__rate-pre {
color: var(--m-c-ink-soft);
font-size: 0.875rem;
}
.m-anomaly-card__rate-arrow { color: var(--m-c-accent); font-size: 0.875rem; }
.m-anomaly-card__rate-post {
color: var(--m-c-ink);
font-weight: 600;
font-size: 0.9375rem;
}
.m-anomaly-card--high .m-anomaly-card__rate-post {
color: var(--m-c-anomaly);
}
.m-anomaly-card__foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--m-space-3);
flex-wrap: wrap;
font-size: 0.6875rem;
color: var(--m-c-ink-soft);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-anomaly-card__time-label { margin-right: 6px; opacity: 0.7; }
.m-anomaly-card__time time { color: var(--m-c-ink); font-weight: 500; }
</style>
@code {
[Parameter, EditorRequired] public AnomalyListItem Item { get; set; } = default!;
[Parameter] public EventCallback<AnomalyListItem> OnClick { get; set; }
private string _severityClass => Item.Severity switch
{
AnomalySeverity.High => "high",
AnomalySeverity.Medium => "medium",
_ => "low",
};
private RenderFragment RenderRateCell(string label, decimal? pre, decimal? post) => builder =>
{
builder.OpenElement(0, "span");
builder.AddAttribute(1, "class", "m-anomaly-card__rate");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", "m-anomaly-card__rate-label");
builder.AddContent(4, label);
builder.CloseElement();
builder.OpenElement(5, "span");
builder.AddAttribute(6, "class", "m-anomaly-card__rate-pre");
builder.AddContent(7, FormatRate(pre));
builder.CloseElement();
builder.OpenElement(8, "span");
builder.AddAttribute(9, "class", "m-anomaly-card__rate-arrow");
builder.AddContent(10, "→");
builder.CloseElement();
builder.OpenElement(11, "span");
builder.AddAttribute(12, "class", "m-anomaly-card__rate-post");
builder.AddContent(13, FormatRate(post));
builder.CloseElement();
builder.CloseElement();
};
private async Task HandleClick()
{
if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item);
}
private async Task HandleKey(KeyboardEventArgs e)
{
if (e.Key == "Enter" || e.Key == " ")
{
if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item);
}
}
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
_ => kind.ToString(),
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code),
};
private static string FormatRate(decimal? r) => r is { } v
? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
: "—";
private static string FormatGap(int seconds)
{
if (seconds <= 0) return "—";
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds);
if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m{1:00}s", (int)ts.TotalMinutes, ts.Seconds);
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h{1:00}m", (int)ts.TotalHours, ts.Minutes);
}
private static string FormatRelative(DateTimeOffset value)
{
var delta = DateTimeOffset.UtcNow - value;
if (delta < TimeSpan.Zero) delta = TimeSpan.Zero;
if (delta.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s ago", (int)delta.TotalSeconds);
if (delta.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m ago", (int)delta.TotalMinutes);
if (delta.TotalHours < 24) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h ago", (int)delta.TotalHours);
return value.ToString("dd MMM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,244 @@
@*
AnomalyEvidence — two-column "before / after" presentation of the parsed
EvidenceJson from an anomaly. Each column shows the snapshot timestamp,
the implied probability per side (with a horizontal bar), and the raw
rate (mono numerals).
The favourite-swap is called out via a single-line statement above the
columns. Tennis (2-way) markets render with no Draw row — handled by
nullable PDraw / RateDraw on the snapshot.
Pure presentation — no data fetching. Callers shape an `AnomalyDetailVm`
and pass `Pre` + `Post` snapshots in.
*@
@inject IStringLocalizer<SharedResource> L
<div class="m-evidence" data-test="anomaly-evidence">
<div class="m-evidence__summary">
<div class="m-evidence__gap">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Anomaly.Evidence.SuspensionDuration"]
</span>
<span class="m-evidence__gap-value m-mono">@FormatGap(SuspensionGapSeconds)</span>
</div>
@if (Pre.Favourite != Post.Favourite && Pre.Favourite != AnomalyFavourite.None && Post.Favourite != AnomalyFavourite.None)
{
<div class="m-evidence__swap" data-test="favourite-swap">
<span class="m-anomaly__pulse" style="background: var(--m-c-anomaly);"></span>
<span class="m-evidence__swap-text">
@L["Anomaly.Evidence.FavouriteSwap"] ·
<strong>@FavLabel(Pre.Favourite) → @FavLabel(Post.Favourite)</strong>
</span>
</div>
}
</div>
<div class="m-evidence__columns">
<article class="m-evidence__col">
<header class="m-evidence__col-head">
<span class="m-kicker">@L["Anomaly.Evidence.Pre"]</span>
<time class="m-mono" datetime="@Pre.CapturedAt.ToString("o")">
@Pre.CapturedAt.ToString("dd MMM HH:mm:ss")
</time>
</header>
@RenderRow(L["Detail.Chart.Win1"], Pre.P1, Pre.Rate1, IsFavourite(Pre, AnomalyFavourite.Side1))
@if (!IsTwoWay)
{
@RenderRow(L["Detail.Chart.Draw"], Pre.PDraw, Pre.RateDraw, IsFavourite(Pre, AnomalyFavourite.Draw))
}
@RenderRow(L["Detail.Chart.Win2"], Pre.P2, Pre.Rate2, IsFavourite(Pre, AnomalyFavourite.Side2))
</article>
<div class="m-evidence__arrow" aria-hidden="true">→</div>
<article class="m-evidence__col m-evidence__col--post">
<header class="m-evidence__col-head">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Anomaly.Evidence.Post"]
</span>
<time class="m-mono" datetime="@Post.CapturedAt.ToString("o")">
@Post.CapturedAt.ToString("dd MMM HH:mm:ss")
</time>
</header>
@RenderRow(L["Detail.Chart.Win1"], Post.P1, Post.Rate1, IsFavourite(Post, AnomalyFavourite.Side1))
@if (!IsTwoWay)
{
@RenderRow(L["Detail.Chart.Draw"], Post.PDraw, Post.RateDraw, IsFavourite(Post, AnomalyFavourite.Draw))
}
@RenderRow(L["Detail.Chart.Win2"], Post.P2, Post.Rate2, IsFavourite(Post, AnomalyFavourite.Side2))
</article>
</div>
</div>
<style>
.m-evidence {
display: grid;
gap: var(--m-space-4);
}
.m-evidence__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--m-space-4);
flex-wrap: wrap;
}
.m-evidence__gap {
display: flex;
flex-direction: column;
gap: 6px;
}
.m-evidence__gap-value {
font-size: 1.5rem;
font-weight: 500;
color: var(--m-c-ink);
}
.m-evidence__swap {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: color-mix(in srgb, var(--m-c-anomaly) 8%, transparent);
border: 1px solid var(--m-c-anomaly);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.75rem;
}
.m-evidence__swap-text strong { font-weight: 600; }
.m-evidence__columns {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--m-space-4);
align-items: stretch;
}
@@media (max-width: 720px) {
.m-evidence__columns { grid-template-columns: 1fr; }
.m-evidence__arrow { display: none; }
}
.m-evidence__col {
display: grid;
gap: var(--m-space-3);
padding: var(--m-space-4);
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
}
.m-evidence__col--post {
border-left: 3px solid var(--m-c-anomaly);
}
.m-evidence__col-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--m-space-3);
flex-wrap: wrap;
}
.m-evidence__col-head time {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-evidence__arrow {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--m-font-display);
font-size: 1.75rem;
color: var(--m-c-accent);
}
.m-evidence__row {
display: grid;
grid-template-columns: 60px minmax(0, 1fr) 56px;
gap: var(--m-space-3);
align-items: center;
}
.m-evidence__row-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-evidence__row.is-favourite .m-evidence__row-label {
color: var(--m-c-ink);
font-weight: 600;
}
.m-evidence__bar {
height: 8px;
background: var(--m-c-rule);
position: relative;
overflow: hidden;
border-radius: 1px;
}
.m-evidence__bar-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--m-c-ink-soft);
transition: width 240ms ease;
}
.m-evidence__col--post .m-evidence__bar-fill { background: var(--m-c-anomaly); }
.m-evidence__row.is-favourite .m-evidence__bar-fill { background: var(--m-c-accent); }
.m-evidence__col--post .m-evidence__row.is-favourite .m-evidence__bar-fill {
background: var(--m-c-anomaly);
}
.m-evidence__rate {
text-align: right;
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-weight: 500;
}
</style>
@code {
[Parameter, EditorRequired] public AnomalyEvidenceSnapshot Pre { get; set; } = default!;
[Parameter, EditorRequired] public AnomalyEvidenceSnapshot Post { get; set; } = default!;
[Parameter] public int SuspensionGapSeconds { get; set; }
[Parameter] public bool IsTwoWay { get; set; }
private RenderFragment RenderRow(string label, decimal? probability, decimal? rate, bool isFavourite) => builder =>
{
var pct = (probability ?? 0m) * 100m;
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", isFavourite ? "m-evidence__row is-favourite" : "m-evidence__row");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", "m-evidence__row-label");
builder.AddContent(4, label);
builder.CloseElement();
builder.OpenElement(5, "div");
builder.AddAttribute(6, "class", "m-evidence__bar");
builder.AddAttribute(7, "role", "img");
builder.AddAttribute(8, "aria-label", string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0}%", pct));
builder.OpenElement(9, "span");
builder.AddAttribute(10, "class", "m-evidence__bar-fill");
builder.AddAttribute(11, "style", string.Format(System.Globalization.CultureInfo.InvariantCulture, "width: {0:0.0}%;", Math.Clamp(pct, 0m, 100m)));
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(12, "span");
builder.AddAttribute(13, "class", "m-evidence__rate");
builder.AddContent(14, rate is { } r ? r.ToString("0.00") : "—");
builder.CloseElement();
builder.CloseElement();
};
private static bool IsFavourite(AnomalyEvidenceSnapshot s, AnomalyFavourite side) => s.Favourite == side;
private string FavLabel(AnomalyFavourite f) => f switch
{
AnomalyFavourite.Side1 => L["Detail.Chart.Win1"],
AnomalyFavourite.Draw => L["Detail.Chart.Draw"],
AnomalyFavourite.Side2 => L["Detail.Chart.Win2"],
_ => "—",
};
private static string FormatGap(int seconds)
{
if (seconds <= 0) return "—";
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds);
if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)ts.TotalMinutes, ts.Seconds);
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h {1:00}m", (int)ts.TotalHours, ts.Minutes);
}
}
+44
View File
@@ -1,4 +1,6 @@
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject AnomalyBrowsingState AnomalyState
<nav class="m-nav" aria-label="primary">
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
@@ -26,6 +28,12 @@
<NavLink class="m-nav__link" href="anomalies">
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
<span>@L["Nav.Anomalies"]</span>
@if (AnomalyState.UnreadCount > 0)
{
<span class="m-nav__badge" data-test="anomaly-badge" aria-label="@L["Anomaly.Nav.UnreadAria"]">
@AnomalyState.UnreadCount
</span>
}
</NavLink>
<NavLink class="m-nav__link" href="results">
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
@@ -38,3 +46,39 @@
<span>@L["Nav.Settings"]</span>
</NavLink>
</nav>
<style>
.m-nav__badge {
margin-left: auto;
min-width: 18px;
padding: 0 6px;
background: var(--m-c-anomaly);
color: #ffffff;
font-family: var(--m-font-mono);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.05em;
line-height: 18px;
height: 18px;
text-align: center;
border-radius: var(--m-radius-xs);
animation: m-pulse 1.6s ease-in-out infinite;
}
@@media (prefers-reduced-motion: reduce) {
.m-nav__badge { animation: none; }
}
</style>
@code {
protected override void OnInitialized()
{
AnomalyState.OnChange += OnAnomalyStateChanged;
}
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose()
{
AnomalyState.OnChange -= OnAnomalyStateChanged;
}
}
@@ -0,0 +1,94 @@
@*
SeverityBadge — small uppercase pill encoding an anomaly's severity bucket.
The High variant is signal-red (`--m-c-anomaly`) and pulses to draw the eye
on the feed page. Medium uses the editorial amber accent. Low is a muted
neutral so it does not compete with higher severities.
The component is presentational only — callers compute the severity (via
`AnomalySeverityRules.FromScore`) and pass it in.
*@
@inject IStringLocalizer<SharedResource> L
<span class="m-severity m-severity--@_classKey @AdditionalClass" data-severity="@Severity" data-test="severity-badge">
@if (ShowDot)
{
<span class="m-severity__dot" aria-hidden="true"></span>
}
<span class="m-severity__label">@Label</span>
@if (ShowScore && Score is { } s)
{
<span class="m-severity__score m-mono" aria-hidden="true">@s.ToString("0.00")</span>
}
</span>
<style>
.m-severity {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.16em;
border-radius: var(--m-radius-xs);
border: 1px solid currentColor;
white-space: nowrap;
}
.m-severity__dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
flex: 0 0 auto;
}
.m-severity__score {
font-feature-settings: var(--m-num-feature);
opacity: 0.78;
font-size: 0.625rem;
letter-spacing: 0.08em;
}
.m-severity--low {
color: var(--m-c-ink-soft);
background: color-mix(in srgb, var(--m-c-ink-soft) 8%, transparent);
}
.m-severity--medium {
color: var(--m-c-accent);
background: color-mix(in srgb, var(--m-c-accent) 12%, transparent);
}
.m-severity--high {
color: var(--m-c-anomaly);
background: color-mix(in srgb, var(--m-c-anomaly) 12%, transparent);
}
.m-severity--high .m-severity__dot {
animation: m-pulse 1.6s ease-in-out infinite;
}
@@media (prefers-reduced-motion: reduce) {
.m-severity--high .m-severity__dot { animation: none; opacity: 1; }
}
</style>
@code {
[Parameter, EditorRequired] public AnomalySeverity Severity { get; set; }
[Parameter] public decimal? Score { get; set; }
[Parameter] public bool ShowScore { get; set; } = true;
[Parameter] public bool ShowDot { get; set; } = true;
[Parameter] public string? AdditionalClass { get; set; }
private string _classKey => Severity switch
{
AnomalySeverity.High => "high",
AnomalySeverity.Medium => "medium",
_ => "low",
};
private string Label => Severity switch
{
AnomalySeverity.High => L["Anomaly.Severity.High"],
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
_ => L["Anomaly.Severity.Low"],
};
}
-5
View File
@@ -1,5 +0,0 @@
@page "/anomalies"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Anomalies"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Anomalies"]" />
@@ -0,0 +1,304 @@
@page "/anomalies"
@using Marathon.UI.Components
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IAnomalyBrowsingService Anomalies
@inject AnomalyBrowsingState State
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Anomaly.Title"]</PageTitle>
<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" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Nav.Section.Analysis"]
</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Anomaly.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Anomaly.Lede"]</p>
<dl class="m-anomaly-feed__stats" aria-label="@L["Anomaly.Title"]">
<div class="m-anomaly-feed__stat">
<dt>@L["Anomaly.Stat.Total"]</dt>
<dd class="m-mono">@_items.Count</dd>
</div>
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--high">
<dt>@L["Anomaly.Severity.High"]</dt>
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.High)</dd>
</div>
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--medium">
<dt>@L["Anomaly.Severity.Medium"]</dt>
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Medium)</dd>
</div>
<div class="m-anomaly-feed__stat">
<dt>@L["Anomaly.Severity.Low"]</dt>
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Low)</dd>
</div>
</dl>
</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 m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Severity"]</span>
@foreach (var severity in _severityOptions)
{
var current = severity;
var active = _filter.MinSeverity == current;
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
data-test="severity-chip"
data-severity="@current"
@onclick="() => ToggleSeverity(current)">
@SeverityLabel(current)
</button>
}
<button type="button"
class="m-chip @(_filter.MinSeverity is null ? "is-active" : null)"
aria-pressed="@(_filter.MinSeverity is null)"
data-test="severity-chip-any"
@onclick="ClearSeverity">
@L["Anomaly.Filter.AnySeverity"]
</button>
</div>
@if (_availableSports.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Sport"]</span>
@foreach (var sportCode in _availableSports)
{
var localCode = sportCode;
var active = _filter.SportCodes is { Count: > 0 } sc && sc.Contains(localCode);
var sportLabel = SportLabel(localCode);
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
data-test="sport-chip"
@onclick="() => ToggleSport(localCode)">
<SportIcon Code="@localCode" Label="@sportLabel" ClassName="m-chip__icon" />
<span>@sportLabel</span>
</button>
}
</div>
}
<div class="m-list-toolbar__row">
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
<input class="m-input" type="date" value="@FormatDate(_filter.From)"
aria-label="@L["Anomaly.Filter.From"]"
@onchange="OnFromChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Anomaly.Filter.To"]</label>
<input class="m-input" type="date" value="@FormatDate(_filter.To)"
aria-label="@L["Anomaly.Filter.To"]"
@onchange="OnToChanged" />
</div>
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
@L["Anomaly.Filter.MarkRead"]
</button>
</div>
</div>
<div class="m-anomaly-feed m-rise m-rise-3" role="region" aria-label="@L["Anomaly.Title"]" data-test="anomaly-feed">
@if (_loading && _items.Count == 0)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_items.Count == 0)
{
<div class="m-list-empty" data-test="anomaly-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["Anomaly.Empty.NoneInRange"]
</p>
</div>
}
else
{
<div class="m-anomaly-feed__list">
@foreach (var item in _items)
{
<AnomalyCard Item="item" OnClick="HandleClick" />
}
</div>
}
</div>
</section>
<style>
.m-anomaly-feed__stats {
display: flex;
gap: var(--m-space-5);
margin: var(--m-space-3) 0 0;
padding: 0;
flex-wrap: wrap;
}
.m-anomaly-feed__stat {
display: grid;
gap: 2px;
margin: 0;
padding-right: var(--m-space-4);
border-right: 1px solid var(--m-c-rule);
}
.m-anomaly-feed__stat:last-child { border-right: 0; padding-right: 0; }
.m-anomaly-feed__stat dt {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
}
.m-anomaly-feed__stat dd {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
color: var(--m-c-ink);
font-feature-settings: var(--m-num-feature);
}
.m-anomaly-feed__stat--high dd { color: var(--m-c-anomaly); }
.m-anomaly-feed__stat--medium dd { color: var(--m-c-accent); }
.m-anomaly-feed__list {
display: grid;
gap: var(--m-space-3);
}
</style>
@code {
private static readonly AnomalySeverity[] _severityOptions =
{ AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High };
private List<AnomalyListItem> _items = new();
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private bool _loading = true;
private CancellationTokenSource? _loadCts;
private AnomalyFilter _filter = new();
protected override async Task OnInitializedAsync()
{
_filter = State.Filter;
State.OnChange += OnStateChanged;
try
{
_availableSports = await Anomalies.ListKnownSportCodesAsync(CancellationToken.None);
}
catch
{
_availableSports = Array.Empty<int>();
}
await LoadAsync();
}
private void OnStateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task LoadAsync()
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_loading = true;
try
{
var rows = await Anomalies.ListAsync(_filter, ct);
if (ct.IsCancellationRequested) return;
_items = rows.ToList();
var unread = await Anomalies.GetUnreadCountAsync(State.LastSeenUtc, ct);
State.SetUnreadCount(unread);
}
catch (OperationCanceledException) { /* superseded */ }
catch
{
_items = new List<AnomalyListItem>();
}
finally
{
_loading = false;
StateHasChanged();
}
}
private async Task UpdateFilter(AnomalyFilter next)
{
_filter = next;
State.UpdateFilter(next);
await LoadAsync();
}
private Task ToggleSeverity(AnomalySeverity severity)
=> UpdateFilter(_filter with { MinSeverity = _filter.MinSeverity == severity ? null : severity });
private Task ClearSeverity() => UpdateFilter(_filter with { MinSeverity = null });
private Task ToggleSport(int code)
{
var existing = _filter.SportCodes?.ToList() ?? new List<int>();
if (!existing.Remove(code)) existing.Add(code);
return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing });
}
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 void MarkAllRead()
{
State.MarkAllSeen(DateTimeOffset.UtcNow);
}
private void HandleClick(AnomalyListItem item)
{
Nav.NavigateTo($"/anomalies/{item.Id}");
}
private string SeverityLabel(AnomalySeverity s) => s switch
{
AnomalySeverity.High => L["Anomaly.Severity.High"],
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
_ => L["Anomaly.Severity.Low"],
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code),
};
private static string FormatDate(DateTimeOffset? value)
=> value?.ToString("yyyy-MM-dd") ?? string.Empty;
public void Dispose()
{
State.OnChange -= OnStateChanged;
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}
@@ -0,0 +1,113 @@
@page "/anomalies/{Id:guid}"
@using Marathon.UI.Components
@inject IStringLocalizer<SharedResource> L
@inject IAnomalyBrowsingService Anomalies
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Anomaly.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["Anomaly.Detail.NotFound"]</p>
<MudButton Variant="Variant.Outlined" OnClick='() => Nav.NavigateTo("/anomalies")'>
@L["Anomaly.Detail.BackToFeed"]
</MudButton>
</div>
}
else
{
<header class="m-detail-header m-rise m-rise-1">
<div class="m-detail-header__lockup">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@KindLabel(_detail.Item.Kind) · @_detail.Item.CountryCode · @_detail.Item.LeagueId
</span>
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem); margin-top: var(--m-space-2);">
@_detail.Item.EventTitle
</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;">
@L["Anomaly.Card.DetectedAt"] @_detail.Item.DetectedAt.ToString("dd MMM yyyy · HH:mm:ss") · MSK
</div>
</div>
<aside class="m-detail-header__odds">
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Anomaly.Card.Score"]</span>
<SeverityBadge Severity="_detail.Item.Severity" Score="_detail.Item.Score" />
</div>
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Anomaly.Card.GapSeconds"]</span>
<span class="m-mono" data-test="suspension-duration">@FormatGap(_detail.Item.SuspensionGapSeconds)</span>
</div>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.OpenInNew"
OnClick="@(() => Nav.NavigateTo($"/events/{Uri.EscapeDataString(_detail.Item.EventId.Value)}"))"
Class="m-detail-header__export"
data-test="link-back-to-event">
@L["Anomaly.Detail.LinkBackToEvent"]
</MudButton>
</aside>
</header>
<hr class="m-rule" />
<article class="m-card m-card--anomaly m-rise m-rise-2">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Anomaly.Detail.EvidenceTitle"]
</span>
<div style="margin-top: var(--m-space-4);">
<AnomalyEvidence Pre="_detail.Pre"
Post="_detail.Post"
SuspensionGapSeconds="_detail.Item.SuspensionGapSeconds"
IsTwoWay="_detail.Item.IsTwoWay" />
</div>
</article>
}
</section>
@code {
[Parameter] public Guid Id { get; set; }
private AnomalyDetailVm? _detail;
private bool _loading = true;
protected override async Task OnParametersSetAsync()
{
_loading = true;
try
{
_detail = await Anomalies.GetByIdAsync(Id, CancellationToken.None);
}
catch
{
_detail = null;
}
finally
{
_loading = false;
}
}
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
_ => kind.ToString(),
};
private static string FormatGap(int seconds)
{
if (seconds <= 0) return "—";
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds);
if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)ts.TotalMinutes, ts.Seconds);
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h {1:00}m", (int)ts.TotalHours, ts.Minutes);
}
}
+4
View File
@@ -98,6 +98,9 @@
<Field Label="@L["Settings.Workers.ResultsPollIntervalSeconds"]">
<MudNumericField T="int" @bind-Value="_workers.ResultsPollIntervalSeconds" Min="60" Max="7200" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Workers.AnomalyDetectionEnabled"]" Hint="@L["Settings.Workers.AnomalyDetectionEnabled.Hint"]">
<MudSwitch T="bool" @bind-Value="_workers.AnomalyDetectionEnabled" Color="Color.Primary" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
</div>
@@ -197,6 +200,7 @@
LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds,
ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled,
ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds,
AnomalyDetectionEnabled = WorkerOpts.CurrentValue.AnomalyDetectionEnabled,
};
_storage = new StorageOptions
@@ -119,6 +119,8 @@
<data name="Settings.Workers.ResultsPollerEnabled"><value>Results poller enabled</value></data>
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Disabled until Phase 8. Enable only after match-complete polling is implemented.</value></data>
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Results poll interval (sec)</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled"><value>Anomaly detection enabled</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled.Hint"><value>Runs the suspension-flip detector on every cycle. Disable to pause analysis without losing collected snapshots.</value></data>
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
@@ -150,6 +152,37 @@
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 7 — Anomaly feed UI -->
<data name="Anomaly.Title"><value>Anomaly feed</value></data>
<data name="Anomaly.Lede"><value>Real-time signal log of suspension-flip events. The detector runs every cycle, computes implied probabilities before and after each market freeze, and surfaces flips ranked by confidence.</value></data>
<data name="Anomaly.Severity.Low"><value>Low</value></data>
<data name="Anomaly.Severity.Medium"><value>Medium</value></data>
<data name="Anomaly.Severity.High"><value>High</value></data>
<data name="Anomaly.Filter.AnySeverity"><value>Any</value></data>
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
<data name="Anomaly.Filter.Sport"><value>Sport</value></data>
<data name="Anomaly.Filter.From"><value>Detected from</value></data>
<data name="Anomaly.Filter.To"><value>Detected to</value></data>
<data name="Anomaly.Filter.DateRange"><value>Date range</value></data>
<data name="Anomaly.Filter.MarkRead"><value>Mark all read</value></data>
<data name="Anomaly.Card.DetectedAt"><value>Detected</value></data>
<data name="Anomaly.Card.Score"><value>Confidence</value></data>
<data name="Anomaly.Card.Kind"><value>Kind</value></data>
<data name="Anomaly.Card.GapSeconds"><value>Suspension gap</value></data>
<data name="Anomaly.Evidence.Pre"><value>Before suspension</value></data>
<data name="Anomaly.Evidence.Post"><value>After suspension</value></data>
<data name="Anomaly.Evidence.Probability"><value>Implied prob.</value></data>
<data name="Anomaly.Evidence.Rate"><value>Rate</value></data>
<data name="Anomaly.Evidence.SuspensionDuration"><value>Suspension duration</value></data>
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
<data name="Anomaly.Stat.Total"><value>Total</value></data>
<data name="Anomaly.Nav.UnreadAria"><value>Unread anomalies</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>
@@ -125,6 +125,8 @@
<data name="Settings.Workers.ResultsPollerEnabled"><value>Сборщик результатов включён</value></data>
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Отключён до Phase 8. Включите только после реализации опроса match-complete.</value></data>
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Интервал сборщика результатов (сек)</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled"><value>Детектор аномалий включён</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled.Hint"><value>Запускает детектор разворота после паузы на каждом цикле. Отключение приостанавливает анализ без потери накопленных снимков.</value></data>
<!-- Settings — Storage -->
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
@@ -163,6 +165,37 @@
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 7 — Лента аномалий -->
<data name="Anomaly.Title"><value>Лента аномалий</value></data>
<data name="Anomaly.Lede"><value>Сигнальный журнал «разворотов» в реальном времени. Детектор проходит каждый цикл, считает подразумеваемые вероятности до и после каждой заморозки рынка и ранжирует находки по уверенности.</value></data>
<data name="Anomaly.Severity.Low"><value>Низкая</value></data>
<data name="Anomaly.Severity.Medium"><value>Средняя</value></data>
<data name="Anomaly.Severity.High"><value>Высокая</value></data>
<data name="Anomaly.Filter.AnySeverity"><value>Любая</value></data>
<data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data>
<data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data>
<data name="Anomaly.Filter.From"><value>Обнаружено с</value></data>
<data name="Anomaly.Filter.To"><value>Обнаружено по</value></data>
<data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data>
<data name="Anomaly.Filter.MarkRead"><value>Отметить прочитанными</value></data>
<data name="Anomaly.Card.DetectedAt"><value>Обнаружено</value></data>
<data name="Anomaly.Card.Score"><value>Уверенность</value></data>
<data name="Anomaly.Card.Kind"><value>Тип</value></data>
<data name="Anomaly.Card.GapSeconds"><value>Длительность паузы</value></data>
<data name="Anomaly.Evidence.Pre"><value>До паузы</value></data>
<data name="Anomaly.Evidence.Post"><value>После паузы</value></data>
<data name="Anomaly.Evidence.Probability"><value>Подразум. вер.</value></data>
<data name="Anomaly.Evidence.Rate"><value>Кэф</value></data>
<data name="Anomaly.Evidence.SuspensionDuration"><value>Длительность паузы</value></data>
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
<data name="Anomaly.Stat.Total"><value>Всего</value></data>
<data name="Anomaly.Nav.UnreadAria"><value>Непрочитанные аномалии</value></data>
<!-- Phase 6 — Список матчей / Лайв / Детали / Экспорт -->
<data name="PreMatch.Title"><value>Расписание до матча</value></data>
<data name="PreMatch.Lede"><value>Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде.</value></data>
@@ -0,0 +1,253 @@
using System.Text.Json;
using Marathon.Application.Abstractions;
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 anomaly browsing service. Loads anomalies + their
/// originating events in a single pass, parses <c>EvidenceJson</c>, and shapes
/// <see cref="AnomalyListItem"/> / <see cref="AnomalyDetailVm"/> records for
/// the UI to consume directly.
/// </summary>
public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
{
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
public AnomalyBrowsingService(IAnomalyRepository anomalies, IEventRepository events)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
public async Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(filter);
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
// Resolve event metadata in one pass — distinct EventIds only.
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
var items = new List<AnomalyListItem>(all.Count);
foreach (var anomaly in all)
{
ct.ThrowIfCancellationRequested();
if (TryProject(anomaly, eventLookup, out var item))
{
items.Add(item);
}
}
// Apply filters in-memory (small list, UI page).
IEnumerable<AnomalyListItem> filtered = items;
if (filter.MinSeverity is { } minSeverity)
{
filtered = filtered.Where(i => AnomalySeverityRules.MeetsThreshold(i.Severity, minSeverity));
}
if (filter.SportCodes is { Count: > 0 } sports)
{
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
}
if (filter.From is { } from)
{
filtered = filtered.Where(i => i.DetectedAt >= from);
}
if (filter.To is { } to)
{
filtered = filtered.Where(i => i.DetectedAt <= to);
}
return filtered
.OrderByDescending(static i => i.DetectedAt)
.ToList();
}
public async Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
{
var anomaly = await _anomalies.GetAsync(id, ct).ConfigureAwait(false);
if (anomaly is null) return null;
var eventLookup = await BuildEventLookupAsync(new[] { anomaly }, ct).ConfigureAwait(false);
if (!TryProject(anomaly, eventLookup, out var item)) return null;
if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return null;
var pre = ToSnapshot(dto.PreSuspension);
var post = ToSnapshot(dto.PostSuspension);
return new AnomalyDetailVm(item, pre, post);
}
public async Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
{
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
var count = 0;
foreach (var anomaly in all)
{
if (anomaly.DetectedAt > since) count++;
}
return count;
}
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
{
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
if (all.Count == 0) return Array.Empty<int>();
var lookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
return all
.Select(a => lookup.TryGetValue(a.EventId, out var ev) ? ev.Sport.Value : (int?)null)
.Where(static x => x.HasValue)
.Select(static x => x!.Value)
.Distinct()
.OrderBy(static x => x)
.ToList();
}
// ---------------- internals ----------------
private async Task<IReadOnlyDictionary<DomainEventId, Event>> BuildEventLookupAsync(
IReadOnlyCollection<Anomaly> anomalies,
CancellationToken ct)
{
var distinct = anomalies
.Select(a => a.EventId)
.Distinct()
.ToList();
var dict = new Dictionary<DomainEventId, Event>(distinct.Count);
foreach (var eid in distinct)
{
ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false);
if (ev is not null) dict[eid] = ev;
}
return dict;
}
private static bool TryProject(
Anomaly anomaly,
IReadOnlyDictionary<DomainEventId, Event> events,
out AnomalyListItem item)
{
item = default!;
if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return false;
var severity = AnomalySeverityRules.FromScore(anomaly.Score);
events.TryGetValue(anomaly.EventId, out var ev);
var sport = ev?.Sport ?? new SportCode(0);
var country = ev?.CountryCode ?? string.Empty;
var league = ev?.LeagueId ?? string.Empty;
var title = ev is not null
? $"{ev.Side1Name} vs {ev.Side2Name}"
: anomaly.EventId.Value;
var preSnap = ToSnapshot(dto.PreSuspension);
var postSnap = ToSnapshot(dto.PostSuspension);
var twoWay = dto.PreSuspension.PDraw is null && dto.PostSuspension.PDraw is null;
item = new AnomalyListItem(
anomaly.Id,
anomaly.EventId,
title,
sport,
country,
league,
anomaly.DetectedAt,
anomaly.Score,
severity,
anomaly.Kind,
dto.SuspensionGapSeconds,
preSnap.Rate1,
preSnap.RateDraw,
preSnap.Rate2,
postSnap.Rate1,
postSnap.RateDraw,
postSnap.Rate2,
preSnap.Favourite,
postSnap.Favourite,
twoWay);
return true;
}
private static bool TryParseEvidence(string evidenceJson, out EvidenceDto dto)
{
dto = default!;
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
try
{
var parsed = JsonSerializer.Deserialize<EvidenceDto>(evidenceJson, JsonOptions);
if (parsed?.PreSuspension is null || parsed.PostSuspension is null) return false;
dto = parsed;
return true;
}
catch (JsonException)
{
return false;
}
}
private static AnomalyEvidenceSnapshot ToSnapshot(EvidenceSnapshotDto dto)
{
var fav = ResolveFavourite(dto.P1, dto.PDraw, dto.P2);
return new AnomalyEvidenceSnapshot(
dto.CapturedAt,
dto.Rate1,
dto.RateDraw,
dto.Rate2,
dto.P1,
dto.PDraw,
dto.P2,
fav);
}
private static AnomalyFavourite ResolveFavourite(decimal? p1, decimal? pDraw, decimal? p2)
{
// Favourite = side with the highest implied probability (lowest odds).
var best = AnomalyFavourite.None;
decimal bestValue = decimal.MinValue;
if (p1 is { } v1 && v1 > bestValue) { bestValue = v1; best = AnomalyFavourite.Side1; }
if (pDraw is { } vd && vd > bestValue) { bestValue = vd; best = AnomalyFavourite.Draw; }
if (p2 is { } v2 && v2 > bestValue) { best = AnomalyFavourite.Side2; }
return best;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private sealed class EvidenceDto
{
public int SuspensionGapSeconds { get; set; }
public EvidenceSnapshotDto PreSuspension { get; set; } = default!;
public EvidenceSnapshotDto PostSuspension { get; set; } = default!;
}
private sealed class EvidenceSnapshotDto
{
public DateTimeOffset CapturedAt { get; set; }
public decimal? P1 { get; set; }
public decimal? PDraw { get; set; }
public decimal? P2 { get; set; }
public decimal? Rate1 { get; set; }
public decimal? RateDraw { get; set; }
public decimal? Rate2 { get; set; }
}
}
@@ -0,0 +1,59 @@
namespace Marathon.UI.Services;
/// <summary>
/// Singleton (per RCL) anomaly feed state — current filter, last-seen
/// timestamp for the unread-badge, and a cached unread count.
/// Pages produce new <see cref="AnomalyFilter"/> instances and call
/// <see cref="UpdateFilter"/>; the OnChange event re-renders subscribers.
/// </summary>
/// <remarks>
/// LastSeenUtc is held in memory; the host can persist it through the standard
/// settings writer if desired. It seeds to <c>DateTimeOffset.MinValue</c> so
/// the first session shows every anomaly as unread.
/// </remarks>
public sealed class AnomalyBrowsingState
{
private AnomalyFilter _filter = new();
private DateTimeOffset _lastSeenUtc = DateTimeOffset.MinValue;
private int _unreadCount;
public AnomalyFilter Filter => _filter;
public DateTimeOffset LastSeenUtc => _lastSeenUtc;
public int UnreadCount => _unreadCount;
public event Action? OnChange;
public void UpdateFilter(AnomalyFilter next)
{
ArgumentNullException.ThrowIfNull(next);
if (Equals(_filter, next)) return;
_filter = next;
OnChange?.Invoke();
}
public void MarkAllSeen(DateTimeOffset utcNow)
{
if (_lastSeenUtc == utcNow && _unreadCount == 0) return;
_lastSeenUtc = utcNow;
_unreadCount = 0;
OnChange?.Invoke();
}
public void SetUnreadCount(int count)
{
var clamped = count < 0 ? 0 : count;
if (_unreadCount == clamped) return;
_unreadCount = clamped;
OnChange?.Invoke();
}
public void SeedLastSeen(DateTimeOffset value)
{
// Used by hosts that persist the last-seen timestamp across restarts.
if (_lastSeenUtc == value) return;
_lastSeenUtc = value;
OnChange?.Invoke();
}
}
@@ -0,0 +1,105 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// Severity bucket derived from <see cref="AnomalyListItem.Score"/>.
/// Phase 7 mapping (see backend handoff):
/// Low = [0.30, 0.45), Medium = [0.45, 0.60), High = [0.60, 1.00].
/// </summary>
public enum AnomalySeverity
{
Low,
Medium,
High,
}
/// <summary>
/// Filter state passed from a page to <see cref="IAnomalyBrowsingService"/>.
/// All fields optional — empty filter returns the full feed.
/// </summary>
public sealed record AnomalyFilter(
AnomalySeverity? MinSeverity = null,
IReadOnlyCollection<int>? SportCodes = null,
DateTimeOffset? From = null,
DateTimeOffset? To = null);
/// <summary>
/// Compact anomaly row used by the feed page. Designed to render without any
/// further repository calls — pre-shaped strings + parsed evidence summary.
/// </summary>
public sealed record AnomalyListItem(
Guid Id,
EventId EventId,
string EventTitle,
SportCode Sport,
string CountryCode,
string LeagueId,
DateTimeOffset DetectedAt,
decimal Score,
AnomalySeverity Severity,
AnomalyKind Kind,
int SuspensionGapSeconds,
decimal? PreWin1Rate,
decimal? PreDrawRate,
decimal? PreWin2Rate,
decimal? PostWin1Rate,
decimal? PostDrawRate,
decimal? PostWin2Rate,
AnomalyFavourite PreFavourite,
AnomalyFavourite PostFavourite,
bool IsTwoWay);
/// <summary>
/// Full anomaly aggregate for the detail page. Carries the parsed evidence
/// snapshots plus the originating event metadata for the link-back affordance.
/// </summary>
public sealed record AnomalyDetailVm(
AnomalyListItem Item,
AnomalyEvidenceSnapshot Pre,
AnomalyEvidenceSnapshot Post);
/// <summary>
/// Snapshot bracket of the suspension window — pre or post — with raw rates,
/// implied probabilities, and the side that was the favourite at that moment.
/// </summary>
public sealed record AnomalyEvidenceSnapshot(
DateTimeOffset CapturedAt,
decimal? Rate1,
decimal? RateDraw,
decimal? Rate2,
decimal? P1,
decimal? PDraw,
decimal? P2,
AnomalyFavourite Favourite);
/// <summary>Side that holds the lowest implied probability in a snapshot.</summary>
public enum AnomalyFavourite
{
Side1,
Draw,
Side2,
None,
}
/// <summary>Helpers for severity bucketing.</summary>
public static class AnomalySeverityRules
{
public const decimal LowThreshold = 0.30m;
public const decimal MediumThreshold = 0.45m;
public const decimal HighThreshold = 0.60m;
public static AnomalySeverity FromScore(decimal score) => score switch
{
< MediumThreshold => AnomalySeverity.Low,
< HighThreshold => AnomalySeverity.Medium,
_ => AnomalySeverity.High,
};
public static bool MeetsThreshold(AnomalySeverity actual, AnomalySeverity? minimum)
{
if (minimum is null) return true;
return (int)actual >= (int)minimum.Value;
}
}
@@ -0,0 +1,22 @@
namespace Marathon.UI.Services;
/// <summary>
/// Read-only browsing facade over the Anomaly + Event repositories. Pages
/// depend on this — never on <c>IAnomalyRepository</c> directly — so view-model
/// shaping (severity buckets, evidence parsing, event metadata join) stays in
/// one place.
/// </summary>
public interface IAnomalyBrowsingService
{
/// <summary>List anomalies matching <paramref name="filter"/>, newest first.</summary>
Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct);
/// <summary>Single anomaly aggregate for the detail page; null when not found.</summary>
Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct);
/// <summary>Count of anomalies detected after <paramref name="since"/> — drives the nav badge.</summary>
Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct);
/// <summary>The set of distinct sport codes present in the anomaly feed — used to populate filter chips.</summary>
Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct);
}
@@ -46,9 +46,11 @@ public static class UiServicesExtensions
services.AddSingleton<ThemeState>();
services.AddSingleton<LocaleState>();
services.AddSingleton<EventBrowsingState>();
services.AddSingleton<AnomalyBrowsingState>();
// Browsing facade — Scoped so it captures the per-circuit repository scope.
// Browsing facades — Scoped so they capture the per-circuit repository scope.
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -34,4 +34,11 @@ public sealed class WorkerOptions
/// Default: 300 s (5 minutes).
/// </summary>
public int ResultsPollIntervalSeconds { get; set; } = 300;
/// <summary>
/// Whether the anomaly-detection poller should run.
/// Default: <c>true</c> — this is the product's primary differentiator
/// (Phase 7) and should be enabled by default.
/// </summary>
public bool AnomalyDetectionEnabled { get; set; } = true;
}
@@ -0,0 +1,84 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
using Microsoft.AspNetCore.Components;
namespace Marathon.UI.Tests.Components;
public sealed class AnomalyCardTests : MarathonTestContext
{
[Fact]
public void Renders_event_title_severity_pill_and_kind_kicker()
{
var item = FakeAnomalyBrowsingService.MakeItem(
title: "Lakers vs Bulls",
score: 0.72m);
var cut = RenderComponent<AnomalyCard>(p => p.Add(c => c.Item, item));
cut.Markup.Should().Contain("Lakers vs Bulls");
cut.Find("[data-test=severity-badge]").GetAttribute("class").Should().Contain("m-severity--high");
cut.Markup.Should().Contain("Anomaly.Kind.SuspensionFlip");
}
[Fact]
public void Card_uses_high_severity_left_border_class()
{
var item = FakeAnomalyBrowsingService.MakeItem(score: 0.65m);
var cut = RenderComponent<AnomalyCard>(p => p.Add(c => c.Item, item));
var card = cut.Find("[data-test=anomaly-card]");
card.GetAttribute("class").Should().Contain("m-anomaly-card--high");
}
[Fact]
public void Card_uses_low_severity_left_border_class()
{
var item = FakeAnomalyBrowsingService.MakeItem(score: 0.32m);
var cut = RenderComponent<AnomalyCard>(p => p.Add(c => c.Item, item));
var card = cut.Find("[data-test=anomaly-card]");
card.GetAttribute("class").Should().Contain("m-anomaly-card--low");
}
[Fact]
public void Click_invokes_callback_with_item()
{
var item = FakeAnomalyBrowsingService.MakeItem();
AnomalyListItem? clicked = null;
var cut = RenderComponent<AnomalyCard>(p => p
.Add(c => c.Item, item)
.Add(c => c.OnClick, EventCallback.Factory.Create<AnomalyListItem>(this, x => clicked = x)));
cut.Find("[data-test=anomaly-card]").Click();
clicked.Should().NotBeNull();
clicked!.Id.Should().Be(item.Id);
}
[Fact]
public void Two_way_card_omits_draw_strip_cell()
{
var item = FakeAnomalyBrowsingService.MakeItem(isTwoWay: true);
var cut = RenderComponent<AnomalyCard>(p => p.Add(c => c.Item, item));
// Strip should mention Win1 + Win2 only — not Draw.
cut.Markup.Should().Contain("Detail.Chart.Win1");
cut.Markup.Should().Contain("Detail.Chart.Win2");
cut.Markup.Should().NotContain("Detail.Chart.Draw");
}
[Fact]
public void Three_way_card_renders_draw_strip_cell()
{
var item = FakeAnomalyBrowsingService.MakeItem(
isTwoWay: false,
preDraw: 3.30m,
postDraw: 3.20m);
var cut = RenderComponent<AnomalyCard>(p => p.Add(c => c.Item, item));
cut.Markup.Should().Contain("Detail.Chart.Draw");
}
}
@@ -0,0 +1,115 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Components;
public sealed class AnomalyEvidenceTests : MarathonTestContext
{
[Fact]
public void Renders_two_columns_with_pre_and_post_kickers()
{
var pre = FakeAnomalyBrowsingService.MakeSnapshot();
var post = FakeAnomalyBrowsingService.MakeSnapshot(
rate1: 4.00m,
rate2: 1.30m,
p1: 0.245m,
p2: 0.755m,
favourite: AnomalyFavourite.Side2);
var cut = RenderComponent<AnomalyEvidence>(p => p
.Add(e => e.Pre, pre)
.Add(e => e.Post, post)
.Add(e => e.SuspensionGapSeconds, 90)
.Add(e => e.IsTwoWay, true));
cut.Markup.Should().Contain("Anomaly.Evidence.Pre");
cut.Markup.Should().Contain("Anomaly.Evidence.Post");
cut.FindAll(".m-evidence__col").Count.Should().Be(2);
}
[Fact]
public void Highlights_favourite_swap_when_sides_differ()
{
var pre = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1);
var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2);
var cut = RenderComponent<AnomalyEvidence>(p => p
.Add(e => e.Pre, pre)
.Add(e => e.Post, post)
.Add(e => e.SuspensionGapSeconds, 90)
.Add(e => e.IsTwoWay, true));
cut.FindAll("[data-test=favourite-swap]").Should().NotBeEmpty();
cut.Markup.Should().Contain("Anomaly.Evidence.FavouriteSwap");
}
[Fact]
public void Hides_favourite_swap_callout_when_favourite_unchanged()
{
var pre = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1);
var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1);
var cut = RenderComponent<AnomalyEvidence>(p => p
.Add(e => e.Pre, pre)
.Add(e => e.Post, post)
.Add(e => e.SuspensionGapSeconds, 90)
.Add(e => e.IsTwoWay, true));
cut.FindAll("[data-test=favourite-swap]").Should().BeEmpty();
}
[Fact]
public void Two_way_market_skips_draw_row()
{
var pre = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: null, pDraw: null);
var post = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: null, pDraw: null,
favourite: AnomalyFavourite.Side2);
var cut = RenderComponent<AnomalyEvidence>(p => p
.Add(e => e.Pre, pre)
.Add(e => e.Post, post)
.Add(e => e.SuspensionGapSeconds, 60)
.Add(e => e.IsTwoWay, true));
// 2-way → 4 rows total (2 cols × 2 sides), not 6.
cut.FindAll(".m-evidence__row").Count.Should().Be(4);
cut.Markup.Should().NotContain("Detail.Chart.Draw");
}
[Fact]
public void Three_way_market_includes_draw_row_in_each_column()
{
var pre = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: 3.30m, pDraw: 0.30m);
var post = FakeAnomalyBrowsingService.MakeSnapshot(rateDraw: 3.20m, pDraw: 0.31m,
favourite: AnomalyFavourite.Draw);
var cut = RenderComponent<AnomalyEvidence>(p => p
.Add(e => e.Pre, pre)
.Add(e => e.Post, post)
.Add(e => e.SuspensionGapSeconds, 120)
.Add(e => e.IsTwoWay, false));
// 3-way → 6 rows total (2 cols × 3 sides).
cut.FindAll(".m-evidence__row").Count.Should().Be(6);
cut.Markup.Should().Contain("Detail.Chart.Draw");
}
[Fact]
public void Renders_suspension_duration_label()
{
var pre = FakeAnomalyBrowsingService.MakeSnapshot();
var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2);
var cut = RenderComponent<AnomalyEvidence>(p => p
.Add(e => e.Pre, pre)
.Add(e => e.Post, post)
.Add(e => e.SuspensionGapSeconds, 134)
.Add(e => e.IsTwoWay, true));
cut.Markup.Should().Contain("Anomaly.Evidence.SuspensionDuration");
// 134s -> "2m 14s"
cut.Markup.Should().Contain("2m 14s");
}
}
@@ -0,0 +1,69 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Components;
public sealed class SeverityBadgeTests : MarathonTestContext
{
[Theory]
[InlineData(0.30, AnomalySeverity.Low)]
[InlineData(0.44, AnomalySeverity.Low)]
[InlineData(0.45, AnomalySeverity.Medium)]
[InlineData(0.59, AnomalySeverity.Medium)]
[InlineData(0.60, AnomalySeverity.High)]
[InlineData(0.95, AnomalySeverity.High)]
public void From_score_buckets_correctly(double score, AnomalySeverity expected)
{
AnomalySeverityRules.FromScore((decimal)score).Should().Be(expected);
}
[Fact]
public void Renders_high_severity_pill_with_anomaly_class()
{
var cut = RenderComponent<SeverityBadge>(p => p
.Add(b => b.Severity, AnomalySeverity.High)
.Add(b => b.Score, 0.72m));
var pill = cut.Find("[data-test=severity-badge]");
pill.GetAttribute("class").Should().Contain("m-severity--high");
cut.Markup.Should().Contain("Anomaly.Severity.High");
cut.Markup.Should().Contain("0.72");
}
[Fact]
public void Renders_medium_severity_pill_with_amber_accent_class()
{
var cut = RenderComponent<SeverityBadge>(p => p
.Add(b => b.Severity, AnomalySeverity.Medium)
.Add(b => b.Score, 0.50m));
var pill = cut.Find("[data-test=severity-badge]");
pill.GetAttribute("class").Should().Contain("m-severity--medium");
cut.Markup.Should().Contain("Anomaly.Severity.Medium");
}
[Fact]
public void Renders_low_severity_pill_with_neutral_class()
{
var cut = RenderComponent<SeverityBadge>(p => p
.Add(b => b.Severity, AnomalySeverity.Low)
.Add(b => b.Score, 0.32m));
var pill = cut.Find("[data-test=severity-badge]");
pill.GetAttribute("class").Should().Contain("m-severity--low");
cut.Markup.Should().Contain("Anomaly.Severity.Low");
}
[Fact]
public void Hides_score_when_disabled()
{
var cut = RenderComponent<SeverityBadge>(p => p
.Add(b => b.Severity, AnomalySeverity.High)
.Add(b => b.Score, 0.81m)
.Add(b => b.ShowScore, false));
cut.Markup.Should().NotContain("0.81");
}
}
@@ -0,0 +1,78 @@
using Bunit;
using Marathon.UI.Pages.Anomalies;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Pages.Anomalies;
public sealed class AnomalyDetailTests : MarathonTestContext
{
[Fact]
public void Renders_not_found_when_detail_is_null()
{
AnomalyBrowsing.Detail = null;
var cut = RenderComponent<Detail>(p => p.Add(d => d.Id, Guid.NewGuid()));
cut.WaitForAssertion(() =>
cut.Markup.Should().Contain("Anomaly.Detail.NotFound"));
}
[Fact]
public void Renders_event_title_severity_pill_and_evidence_panel()
{
var item = FakeAnomalyBrowsingService.MakeItem(title: "Lakers vs Bulls", score: 0.72m);
var pre = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side1);
var post = FakeAnomalyBrowsingService.MakeSnapshot(
rate1: 4.00m, rate2: 1.30m,
p1: 0.245m, p2: 0.755m,
favourite: AnomalyFavourite.Side2);
AnomalyBrowsing.Detail = new AnomalyDetailVm(item, pre, post);
var cut = RenderComponent<Detail>(p => p.Add(d => d.Id, item.Id));
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("Lakers vs Bulls");
cut.FindAll("[data-test=severity-badge]").Should().NotBeEmpty();
cut.Markup.Should().Contain("Anomaly.Detail.EvidenceTitle");
cut.FindAll("[data-test=anomaly-evidence]").Should().NotBeEmpty();
});
}
[Fact]
public void Renders_link_back_to_event_button()
{
var item = FakeAnomalyBrowsingService.MakeItem();
var pre = FakeAnomalyBrowsingService.MakeSnapshot();
var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2);
AnomalyBrowsing.Detail = new AnomalyDetailVm(item, pre, post);
var cut = RenderComponent<Detail>(p => p.Add(d => d.Id, item.Id));
cut.WaitForAssertion(() =>
{
cut.FindAll("[data-test=link-back-to-event]").Should().NotBeEmpty();
cut.Markup.Should().Contain("Anomaly.Detail.LinkBackToEvent");
});
}
[Fact]
public void Renders_suspension_duration_in_header_summary()
{
var item = FakeAnomalyBrowsingService.MakeItem(gapSeconds: 134);
var pre = FakeAnomalyBrowsingService.MakeSnapshot();
var post = FakeAnomalyBrowsingService.MakeSnapshot(favourite: AnomalyFavourite.Side2);
AnomalyBrowsing.Detail = new AnomalyDetailVm(item, pre, post);
var cut = RenderComponent<Detail>(p => p.Add(d => d.Id, item.Id));
cut.WaitForAssertion(() =>
{
var node = cut.Find("[data-test=suspension-duration]");
node.TextContent.Should().Contain("2m 14s");
});
}
}
@@ -0,0 +1,113 @@
using Bunit;
using Marathon.UI.Pages.Anomalies;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Pages.Anomalies;
public sealed class AnomalyFeedTests : MarathonTestContext
{
[Fact]
public void Renders_seeded_anomaly_cards()
{
AnomalyBrowsing.SportCodes.AddRange(new[] { 6, 11 });
AnomalyBrowsing.Items.AddRange(new[]
{
FakeAnomalyBrowsingService.MakeItem(title: "Lakers vs Bulls", sport: 6, score: 0.65m),
FakeAnomalyBrowsingService.MakeItem(title: "Arsenal vs Chelsea", sport: 11, score: 0.40m),
});
var cut = RenderComponent<AnomalyFeed>();
cut.WaitForAssertion(() =>
{
cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(2);
cut.Markup.Should().Contain("Lakers vs Bulls");
cut.Markup.Should().Contain("Arsenal vs Chelsea");
});
}
[Fact]
public void Renders_empty_state_when_no_anomalies_match_filter()
{
var cut = RenderComponent<AnomalyFeed>();
cut.WaitForAssertion(() =>
{
cut.FindAll("[data-test=anomaly-empty]").Should().NotBeEmpty();
cut.Markup.Should().Contain("Anomaly.Empty.NoneInRange");
});
}
[Fact]
public void Severity_chip_filter_narrows_the_list()
{
AnomalyBrowsing.Items.AddRange(new[]
{
FakeAnomalyBrowsingService.MakeItem(title: "High event", score: 0.70m),
FakeAnomalyBrowsingService.MakeItem(title: "Low event", score: 0.32m),
});
var cut = RenderComponent<AnomalyFeed>();
cut.WaitForAssertion(() =>
cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(2));
// Click the High severity chip.
var highChip = cut.FindAll("[data-test=severity-chip]")
.First(e => e.GetAttribute("data-severity") == "High");
highChip.Click();
cut.WaitForAssertion(() =>
{
var cards = cut.FindAll("[data-test=anomaly-card]");
cards.Count.Should().Be(1);
cut.Markup.Should().Contain("High event");
cut.Markup.Should().NotContain("Low event");
AnomalyBrowsing.LastFilter.Should().NotBeNull();
AnomalyBrowsing.LastFilter!.MinSeverity.Should().Be(AnomalySeverity.High);
});
}
[Fact]
public void Sport_chip_filter_narrows_the_list()
{
AnomalyBrowsing.SportCodes.AddRange(new[] { 6, 11 });
AnomalyBrowsing.Items.AddRange(new[]
{
FakeAnomalyBrowsingService.MakeItem(title: "Lakers vs Bulls", sport: 6, score: 0.55m),
FakeAnomalyBrowsingService.MakeItem(title: "Arsenal vs Chelsea", sport: 11, score: 0.55m),
});
var cut = RenderComponent<AnomalyFeed>();
cut.WaitForAssertion(() =>
cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(2));
// Pick the basketball chip (sport=6).
var sportChips = cut.FindAll("[data-test=sport-chip]");
sportChips.Should().NotBeEmpty();
sportChips.First().Click();
cut.WaitForAssertion(() =>
{
AnomalyBrowsing.LastFilter.Should().NotBeNull();
AnomalyBrowsing.LastFilter!.SportCodes.Should().NotBeNull();
AnomalyBrowsing.LastFilter!.SportCodes!.Should().HaveCount(1);
});
}
[Fact]
public void Mark_read_button_clears_unread_count_in_state()
{
AnomalyState.SetUnreadCount(5);
AnomalyBrowsing.UnreadCount = 0;
AnomalyBrowsing.Items.Add(FakeAnomalyBrowsingService.MakeItem());
var cut = RenderComponent<AnomalyFeed>();
cut.WaitForAssertion(() =>
cut.FindAll("[data-test=anomaly-card]").Count.Should().Be(1));
cut.Find("[data-test=mark-read]").Click();
AnomalyState.UnreadCount.Should().Be(0);
AnomalyState.LastSeenUtc.Should().BeAfter(DateTimeOffset.MinValue);
}
}
@@ -0,0 +1,110 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.UI.Services;
namespace Marathon.UI.Tests.Support;
/// <summary>
/// In-memory <see cref="IAnomalyBrowsingService"/> for bUnit tests.
/// Seed via <see cref="Items"/> / <see cref="Detail"/>.
/// </summary>
public sealed class FakeAnomalyBrowsingService : IAnomalyBrowsingService
{
public List<AnomalyListItem> Items { get; } = new();
public AnomalyDetailVm? Detail { get; set; }
public List<int> SportCodes { get; } = new();
public int UnreadCount { get; set; }
public int ListCallCount { get; private set; }
public AnomalyFilter? LastFilter { get; private set; }
public Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct)
{
ListCallCount++;
LastFilter = filter;
IEnumerable<AnomalyListItem> q = Items;
if (filter.MinSeverity is { } min)
{
q = q.Where(i => AnomalySeverityRules.MeetsThreshold(i.Severity, min));
}
if (filter.SportCodes is { Count: > 0 } sports)
{
q = q.Where(i => sports.Contains(i.Sport.Value));
}
if (filter.From is { } from) q = q.Where(i => i.DetectedAt >= from);
if (filter.To is { } to) q = q.Where(i => i.DetectedAt <= to);
return Task.FromResult<IReadOnlyList<AnomalyListItem>>(
q.OrderByDescending(static i => i.DetectedAt).ToList());
}
public Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
=> Task.FromResult(Detail);
public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
=> Task.FromResult(UnreadCount);
public Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<int>>(SportCodes);
/// <summary>Builds an <see cref="AnomalyListItem"/> for tests with sane defaults.</summary>
public static AnomalyListItem MakeItem(
Guid? id = null,
string eventCode = "EV-A",
string title = "Arsenal vs Chelsea",
int sport = 11,
string country = "ENG",
string league = "Premier",
DateTimeOffset? detectedAt = null,
decimal score = 0.55m,
int gapSeconds = 90,
decimal? preWin1 = 1.30m,
decimal? preDraw = null,
decimal? preWin2 = 4.00m,
decimal? postWin1 = 4.00m,
decimal? postDraw = null,
decimal? postWin2 = 1.30m,
AnomalyFavourite preFav = AnomalyFavourite.Side1,
AnomalyFavourite postFav = AnomalyFavourite.Side2,
bool isTwoWay = true)
=> new(
id ?? Guid.NewGuid(),
new EventId(eventCode),
title,
new SportCode(sport),
country,
league,
detectedAt ?? DateTimeOffset.UtcNow.AddMinutes(-2),
score,
AnomalySeverityRules.FromScore(score),
AnomalyKind.SuspensionFlip,
gapSeconds,
preWin1,
preDraw,
preWin2,
postWin1,
postDraw,
postWin2,
preFav,
postFav,
isTwoWay);
public static AnomalyEvidenceSnapshot MakeSnapshot(
DateTimeOffset? capturedAt = null,
decimal? rate1 = 1.30m,
decimal? rateDraw = null,
decimal? rate2 = 4.00m,
decimal? p1 = 0.755m,
decimal? pDraw = null,
decimal? p2 = 0.245m,
AnomalyFavourite favourite = AnomalyFavourite.Side1)
=> new(
capturedAt ?? DateTimeOffset.UtcNow.AddMinutes(-3),
rate1,
rateDraw,
rate2,
p1,
pDraw,
p2,
favourite);
}
@@ -21,6 +21,8 @@ public abstract class MarathonTestContext : TestContext
protected LocaleState Locale { get; } = new();
protected EventBrowsingState BrowsingState { get; } = new();
protected FakeEventBrowsingService Browsing { get; } = new();
protected AnomalyBrowsingState AnomalyState { get; } = new();
protected FakeAnomalyBrowsingService AnomalyBrowsing { get; } = new();
protected MarathonTestContext()
{
@@ -30,6 +32,8 @@ public abstract class MarathonTestContext : TestContext
Services.AddSingleton(Locale);
Services.AddSingleton(BrowsingState);
Services.AddSingleton<IEventBrowsingService>(Browsing);
Services.AddSingleton(AnomalyState);
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));