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
@@ -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;
}
}