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