Files
maraphon-app/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor
T
alexei.dolgolyov 292223174c feat(insights): anomaly outcome validator — hit-rate calibration page
Adds a calibration dashboard that joins persisted SuspensionFlip anomalies
with EventResult rows and reports whether the post-flip favourite actually
won — the single metric that says whether the detector is doing its job.

Domain:
- AnomalyEvidenceData + AnomalyEvidenceParser to read the JSON written by
  AnomalyDetector without re-implementing the schema.
- AnomalyOutcomeEvaluator: pure function returning Hit / Miss / Unresolved.
  Tennis-style two-way markets with a Draw winner are downgraded to
  Unresolved rather than silently counted as Miss.
- AnomalySeverityThresholds: shared Low/Medium/High constants so the UI
  badge and the report buckets cannot drift.

Application:
- EvaluateAnomalyOutcomesUseCase orchestrates the join + aggregation.
- AnomalyOutcomeReport carries totals, hit rate, three breakdowns
  (severity / sport / score bins) and a per-event title lookup so the UI
  needs no second pass over IEventRepository.
- Score bins extend below 0.30 automatically when the operator lowers the
  detector threshold so the histogram total always equals ResolvedCount.

UI:
- Insights page at /anomalies/insights — hero header, 4-card KPI strip
  (hit rate tinted by tone), three breakdown grids with bar visualisation,
  drill-down tables for resolved and unresolved anomalies. Honors
  prefers-reduced-motion. RU + EN localisation.
- Nav entry under Analysis section + chip button on the Anomaly Feed.

Tests: +42 across Domain + Application (evaluator boundary cases including
tennis two-way and Draw guard, score-bin edges, dynamic floor when
threshold is lowered, event-title pass-through). All 324 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:53:31 +03:00

304 lines
11 KiB
Plaintext

@page "/anomalies"
@using Marathon.UI.Components
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IAnomalyBrowsingService Anomalies
@inject AnomalyBrowsingState State
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Anomaly.Title"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Nav.Section.Analysis"]
</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Anomaly.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Anomaly.Lede"]</p>
<dl class="m-anomaly-feed__stats" aria-label="@L["Anomaly.Title"]">
<div class="m-anomaly-feed__stat">
<dt>@L["Anomaly.Stat.Total"]</dt>
<dd class="m-mono">@_items.Count</dd>
</div>
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--high">
<dt>@L["Anomaly.Severity.High"]</dt>
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.High)</dd>
</div>
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--medium">
<dt>@L["Anomaly.Severity.Medium"]</dt>
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Medium)</dd>
</div>
<div class="m-anomaly-feed__stat">
<dt>@L["Anomaly.Severity.Low"]</dt>
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Low)</dd>
</div>
</dl>
</header>
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["PreMatch.Filter.Toolbar"]">
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Severity"]</span>
@foreach (var severity in _severityOptions)
{
var current = severity;
var active = _filter.MinSeverity == current;
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
data-test="severity-chip"
data-severity="@current"
@onclick="() => ToggleSeverity(current)">
@SeverityLabel(current)
</button>
}
<button type="button"
class="m-chip @(_filter.MinSeverity is null ? "is-active" : null)"
aria-pressed="@(_filter.MinSeverity is null)"
data-test="severity-chip-any"
@onclick="ClearSeverity">
@L["Anomaly.Filter.AnySeverity"]
</button>
</div>
@if (_availableSports.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Sport"]</span>
@foreach (var sportCode in _availableSports)
{
var localCode = sportCode;
var active = _filter.SportCodes is { Count: > 0 } sc && sc.Contains(localCode);
var sportLabel = SportLabel(localCode);
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
data-test="sport-chip"
@onclick="() => ToggleSport(localCode)">
<SportIcon Code="@localCode" Label="@sportLabel" ClassName="m-chip__icon" />
<span>@sportLabel</span>
</button>
}
</div>
}
<div class="m-list-toolbar__row">
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
<input class="m-input" type="date" value="@FormatDate(_filter.From)"
aria-label="@L["Anomaly.Filter.From"]"
@onchange="OnFromChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Anomaly.Filter.To"]</label>
<input class="m-input" type="date" value="@FormatDate(_filter.To)"
aria-label="@L["Anomaly.Filter.To"]"
@onchange="OnToChanged" />
</div>
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
@L["Anomaly.Filter.MarkRead"]
</button>
<button type="button" class="m-chip" @onclick="OpenInsights" data-test="open-insights">
@L["Nav.Insights"]
</button>
</div>
</div>
<div class="m-anomaly-feed m-rise m-rise-3" role="region" aria-label="@L["Anomaly.Title"]" data-test="anomaly-feed">
@if (_loading && _items.Count == 0)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_items.Count == 0)
{
<div class="m-list-empty" data-test="anomaly-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
@L["Anomaly.Empty.NoneInRange"]
</p>
</div>
}
else
{
<div class="m-anomaly-feed__list">
@foreach (var item in _items)
{
<AnomalyCard Item="item" OnClick="HandleClick" />
}
</div>
}
</div>
</section>
<style>
.m-anomaly-feed__stats {
display: flex;
gap: var(--m-space-5);
margin: var(--m-space-3) 0 0;
padding: 0;
flex-wrap: wrap;
}
.m-anomaly-feed__stat {
display: grid;
gap: 2px;
margin: 0;
padding-right: var(--m-space-4);
border-right: 1px solid var(--m-c-rule);
}
.m-anomaly-feed__stat:last-child { border-right: 0; padding-right: 0; }
.m-anomaly-feed__stat dt {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
}
.m-anomaly-feed__stat dd {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
color: var(--m-c-ink);
font-feature-settings: var(--m-num-feature);
}
.m-anomaly-feed__stat--high dd { color: var(--m-c-anomaly); }
.m-anomaly-feed__stat--medium dd { color: var(--m-c-accent); }
.m-anomaly-feed__list {
display: grid;
gap: var(--m-space-3);
}
</style>
@code {
private static readonly AnomalySeverity[] _severityOptions =
{ AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High };
private List<AnomalyListItem> _items = new();
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private bool _loading = true;
private CancellationTokenSource? _loadCts;
private AnomalyFilter _filter = new();
protected override async Task OnInitializedAsync()
{
_filter = State.Filter;
State.OnChange += OnStateChanged;
try
{
_availableSports = await Anomalies.ListKnownSportCodesAsync(CancellationToken.None);
}
catch
{
_availableSports = Array.Empty<int>();
}
await LoadAsync();
}
private void OnStateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task LoadAsync()
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_loading = true;
try
{
var rows = await Anomalies.ListAsync(_filter, ct);
if (ct.IsCancellationRequested) return;
_items = rows.ToList();
var unread = await Anomalies.GetUnreadCountAsync(State.LastSeenUtc, ct);
State.SetUnreadCount(unread);
}
catch (OperationCanceledException) { /* superseded */ }
catch
{
_items = new List<AnomalyListItem>();
}
finally
{
_loading = false;
StateHasChanged();
}
}
private async Task UpdateFilter(AnomalyFilter next)
{
_filter = next;
State.UpdateFilter(next);
await LoadAsync();
}
private Task ToggleSeverity(AnomalySeverity severity)
=> UpdateFilter(_filter with { MinSeverity = _filter.MinSeverity == severity ? null : severity });
private Task ClearSeverity() => UpdateFilter(_filter with { MinSeverity = null });
private Task ToggleSport(int code)
{
var existing = _filter.SportCodes?.ToList() ?? new List<int>();
if (!existing.Remove(code)) existing.Add(code);
return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing });
}
private async Task OnFromChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, MoscowTime.Offset) });
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
await UpdateFilter(_filter with { To = MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(v.Date)) });
}
}
private void MarkAllRead()
{
State.MarkAllSeen(DateTimeOffset.UtcNow);
}
private void OpenInsights()
{
Nav.NavigateTo("/anomalies/insights");
}
private void HandleClick(AnomalyListItem item)
{
Nav.NavigateTo($"/anomalies/{item.Id}");
}
private string SeverityLabel(AnomalySeverity s) => s switch
{
AnomalySeverity.High => L["Anomaly.Severity.High"],
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
_ => L["Anomaly.Severity.Low"],
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private static string FormatDate(DateTimeOffset? value)
=> value?.ToString("yyyy-MM-dd") ?? string.Empty;
public void Dispose()
{
State.OnChange -= OnStateChanged;
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}