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