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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user