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