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>
This commit is contained in:
@@ -98,6 +98,9 @@
|
||||
<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>
|
||||
|
||||
@@ -269,6 +272,11 @@
|
||||
State.MarkAllSeen(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private void OpenInsights()
|
||||
{
|
||||
Nav.NavigateTo("/anomalies/insights");
|
||||
}
|
||||
|
||||
private void HandleClick(AnomalyListItem item)
|
||||
{
|
||||
Nav.NavigateTo($"/anomalies/{item.Id}");
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
@*
|
||||
Insights — calibration page for the SuspensionFlip detector.
|
||||
|
||||
Loads a precomputed AnomalyInsightsVm and answers the single question that
|
||||
matters: when the bookmaker flipped, did the post-flip favourite actually
|
||||
win? Big numbers up top, three breakdowns in the middle, drill-down tables
|
||||
at the bottom. Same editorial-quant tone as AnomalyFeed / Home.
|
||||
*@
|
||||
|
||||
@page "/anomalies/insights"
|
||||
@using Marathon.Application.Reporting
|
||||
@using Marathon.Domain.AnomalyDetection
|
||||
@using Marathon.Domain.Enums
|
||||
@implements IDisposable
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IAnomalyInsightsService InsightsService
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Insights"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1 m-insights__header" data-test="insights-header">
|
||||
<div class="m-insights__header-text">
|
||||
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||
@L["Insights.Kicker"]
|
||||
</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Insights.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Insights.Lede"]</p>
|
||||
</div>
|
||||
<div class="m-insights__header-actions">
|
||||
<button type="button"
|
||||
class="m-chip m-insights__refresh"
|
||||
@onclick="LoadAsync"
|
||||
disabled="@_loading"
|
||||
data-test="insights-refresh">
|
||||
<span class="m-insights__refresh-glyph @(_loading ? "is-spinning" : null)" aria-hidden="true">↻</span>
|
||||
<span>@L["Insights.Action.Refresh"]</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (_loading && _vm is null)
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="insights-loading">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_errored && _vm is null)
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="insights-error">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-anomaly); color: var(--m-c-anomaly);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
|
||||
@L["Insights.Empty.None"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (_vm is { } vm)
|
||||
{
|
||||
@* ---------- KPI strip ---------- *@
|
||||
<div class="m-insights__kpis m-rise m-rise-2" data-test="insights-kpis">
|
||||
<article class="m-insights__kpi m-insights__kpi--@HitRateTone(vm.HitRate)" data-test="insights-kpi-hitrate">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.HitRate"]</span>
|
||||
<span class="m-insights__kpi-value">@FormatPercent(vm.HitRate)</span>
|
||||
<span class="m-insights__kpi-hint">@L["Insights.Stat.HitRate.Hint"]</span>
|
||||
</article>
|
||||
<article class="m-insights__kpi" data-test="insights-kpi-resolved">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Resolved"]</span>
|
||||
<span class="m-insights__kpi-value">
|
||||
@vm.ResolvedCount<span class="m-insights__kpi-denom"> / @vm.TotalAnomalies</span>
|
||||
</span>
|
||||
<span class="m-insights__kpi-hint">@L["Insights.Stat.Resolved.Hint"]</span>
|
||||
</article>
|
||||
<article class="m-insights__kpi" data-test="insights-kpi-unresolved">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Unresolved"]</span>
|
||||
<span class="m-insights__kpi-value">@vm.UnresolvedCount</span>
|
||||
<span class="m-insights__kpi-hint">@L["Insights.Stat.Unresolved.Hint"]</span>
|
||||
</article>
|
||||
<article class="m-insights__kpi m-insights__kpi--split" data-test="insights-kpi-hitsmisses">
|
||||
<div class="m-insights__split">
|
||||
<div class="m-insights__split-cell">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Hits"]</span>
|
||||
<span class="m-insights__kpi-value m-insights__kpi-value--positive">@vm.HitCount</span>
|
||||
</div>
|
||||
<div class="m-insights__split-divider" aria-hidden="true"></div>
|
||||
<div class="m-insights__split-cell">
|
||||
<span class="m-insights__kpi-label">@L["Insights.Stat.Misses"]</span>
|
||||
<span class="m-insights__kpi-value m-insights__kpi-value--negative">@vm.MissCount</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By severity ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-severity">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.BySeverity"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.BySeverity, BucketRenderKind.Severity)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By sport ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.BySport"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.BySport, BucketRenderKind.Sport)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By score bin (7 fixed rows) ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-score">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.ByScore"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.ByScoreBin, BucketRenderKind.Score)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- Resolved table ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-4" data-test="insights-resolved">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.Resolved"]</span>
|
||||
<span class="m-insights__section-count m-mono">@vm.Resolved.Count</span>
|
||||
</header>
|
||||
|
||||
@if (vm.TotalAnomalies == 0)
|
||||
{
|
||||
<div class="m-list-empty" data-test="insights-empty-none">
|
||||
<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: 56ch;">
|
||||
@L["Insights.Empty.None"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (vm.Resolved.Count == 0)
|
||||
{
|
||||
<div class="m-list-empty" data-test="insights-empty-resolved">
|
||||
<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: 56ch;">
|
||||
@L["Insights.Empty.NoneResolved"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="m-insights__table-wrap">
|
||||
<table class="m-insights__table" data-test="insights-resolved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
|
||||
<th scope="col">@L["Insights.Column.Match"]</th>
|
||||
<th scope="col">@L["Insights.Column.Sport"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
|
||||
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.Winner"]</th>
|
||||
<th scope="col">@L["Insights.Column.Outcome"]</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in vm.Resolved)
|
||||
{
|
||||
var local = row;
|
||||
<tr class="m-insights__row m-insights__row--@OutcomeCss(local.Outcome)"
|
||||
data-test="insights-resolved-row"
|
||||
data-anomaly-id="@local.AnomalyId">
|
||||
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
|
||||
<td style="font-weight: 500;">@local.EventTitle</td>
|
||||
<td>
|
||||
@if (local.Sport is { } sport)
|
||||
{
|
||||
<span class="m-insights__sport">
|
||||
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
|
||||
<span>@SportLabels.Resolve(L, sport.Value)</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="color: var(--m-c-ink-soft);">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right; font-weight: 600;">
|
||||
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||
</td>
|
||||
<td>@SideLabel(local.PreFlipFavourite)</td>
|
||||
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
|
||||
<td>@SideLabel(local.ActualWinner)</td>
|
||||
<td>
|
||||
<span class="m-insights__verdict m-insights__verdict--@OutcomeCss(local.Outcome)">
|
||||
@OutcomeLabel(local.Outcome)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||
class="m-insights__open"
|
||||
data-test="insights-open-link"
|
||||
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||
@onclick:preventDefault>
|
||||
@L["Insights.Action.OpenAnomaly"]
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@* ---------- Unresolved table (only when non-empty) ---------- *@
|
||||
@if (vm.Unresolved.Count > 0)
|
||||
{
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<section class="m-insights__section m-rise m-rise-5" data-test="insights-unresolved">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker" style="color: var(--m-c-ink-soft); border-color: var(--m-c-ink-soft);">
|
||||
@L["Insights.Section.Unresolved"]
|
||||
</span>
|
||||
<span class="m-insights__section-count m-mono">@vm.Unresolved.Count</span>
|
||||
</header>
|
||||
|
||||
<div class="m-insights__table-wrap m-insights__table-wrap--dim">
|
||||
<table class="m-insights__table" data-test="insights-unresolved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
|
||||
<th scope="col">@L["Insights.Column.Match"]</th>
|
||||
<th scope="col">@L["Insights.Column.Sport"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
|
||||
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
|
||||
<th scope="col">@L["Insights.Column.Outcome"]</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in vm.Unresolved)
|
||||
{
|
||||
var local = row;
|
||||
<tr class="m-insights__row m-insights__row--pending"
|
||||
data-test="insights-unresolved-row"
|
||||
data-anomaly-id="@local.AnomalyId">
|
||||
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
|
||||
<td>@local.EventTitle</td>
|
||||
<td>
|
||||
@if (local.Sport is { } sport)
|
||||
{
|
||||
<span class="m-insights__sport">
|
||||
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
|
||||
<span>@SportLabels.Resolve(L, sport.Value)</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="color: var(--m-c-ink-soft);">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right; font-weight: 600;">
|
||||
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||
</td>
|
||||
<td>@SideLabel(local.PreFlipFavourite)</td>
|
||||
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
|
||||
<td>
|
||||
<span class="m-insights__verdict m-insights__verdict--pending">
|
||||
@L["Insights.Outcome.Unresolved"]
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||
class="m-insights__open"
|
||||
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||
@onclick:preventDefault>
|
||||
@L["Insights.Action.OpenAnomaly"]
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-insights__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--m-space-5);
|
||||
align-items: end;
|
||||
}
|
||||
@@media (max-width: 720px) {
|
||||
.m-insights__header { grid-template-columns: 1fr; }
|
||||
.m-insights__header-actions { justify-self: start; }
|
||||
}
|
||||
.m-insights__header-text {
|
||||
display: grid;
|
||||
gap: var(--m-space-3);
|
||||
max-width: 880px;
|
||||
}
|
||||
.m-insights__header-actions { display: flex; gap: var(--m-space-3); }
|
||||
|
||||
.m-insights__refresh {
|
||||
gap: var(--m-space-2);
|
||||
padding: 6px 12px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.m-insights__refresh:disabled { opacity: 0.6; cursor: progress; }
|
||||
.m-insights__refresh-glyph {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: rotate(45deg); }
|
||||
.m-insights__refresh-glyph.is-spinning { animation: m-insights-spin 1.1s linear infinite; }
|
||||
@@keyframes m-insights-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-insights__refresh-glyph.is-spinning { animation: none; }
|
||||
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: none; }
|
||||
}
|
||||
|
||||
/* ---- KPI strip ---- */
|
||||
.m-insights__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
.m-insights__kpi {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-left: 3px solid var(--m-c-rule);
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--m-space-2);
|
||||
position: relative;
|
||||
}
|
||||
.m-insights__kpi--positive { border-left-color: var(--m-c-positive); }
|
||||
.m-insights__kpi--neutral { border-left-color: var(--m-c-accent); }
|
||||
.m-insights__kpi--negative { border-left-color: var(--m-c-anomaly); }
|
||||
.m-insights__kpi-label {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-insights__kpi-value {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-size: clamp(2rem, 3.5vw, 2.625rem);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
.m-insights__kpi--positive .m-insights__kpi-value { color: var(--m-c-positive); }
|
||||
.m-insights__kpi--negative .m-insights__kpi-value { color: var(--m-c-anomaly); }
|
||||
.m-insights__kpi-value--positive { color: var(--m-c-positive); }
|
||||
.m-insights__kpi-value--negative { color: var(--m-c-anomaly); }
|
||||
.m-insights__kpi-denom {
|
||||
font-size: 0.55em;
|
||||
color: var(--m-c-ink-soft);
|
||||
font-weight: 400;
|
||||
}
|
||||
.m-insights__kpi-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-insights__kpi--split { padding: var(--m-space-4) var(--m-space-5); }
|
||||
.m-insights__split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-insights__split-cell { display: flex; flex-direction: column; gap: 6px; }
|
||||
.m-insights__split-cell:last-child { text-align: right; }
|
||||
.m-insights__split-divider {
|
||||
width: 1px;
|
||||
height: 56px;
|
||||
background: var(--m-c-rule);
|
||||
}
|
||||
|
||||
/* ---- Section headers ---- */
|
||||
.m-insights__section { display: grid; gap: var(--m-space-4); }
|
||||
.m-insights__section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-insights__section-count {
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
|
||||
/* ---- Bucket / breakdown grid ---- */
|
||||
.m-insights__buckets {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-insights__bucket-head,
|
||||
.m-insights__bucket-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.4fr) minmax(140px, 1fr) minmax(220px, 2fr);
|
||||
gap: var(--m-space-4);
|
||||
align-items: center;
|
||||
padding: var(--m-space-3) var(--m-space-4);
|
||||
}
|
||||
.m-insights__bucket-head {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink-soft);
|
||||
background: var(--m-c-paper-2);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-insights__bucket-row {
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-insights__bucket-row:last-child { border-bottom: 0; }
|
||||
.m-insights__bucket-row--dim { color: var(--m-c-ink-soft); }
|
||||
.m-insights__bucket-row--dim .m-insights__bucket-label { color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-insights__bucket-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.m-insights__bucket-label--mono {
|
||||
font-family: var(--m-font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.m-insights__bucket-counts {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-insights__bucket-counts strong {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-insights__bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 56px;
|
||||
gap: var(--m-space-3);
|
||||
align-items: center;
|
||||
}
|
||||
.m-insights__bar-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: var(--m-c-paper-2);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-insights__bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--m-c-accent);
|
||||
transition: width 320ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.m-insights__bar-fill--positive { background: var(--m-c-positive); }
|
||||
.m-insights__bar-fill--negative { background: var(--m-c-anomaly); }
|
||||
.m-insights__bar-fill--neutral { background: var(--m-c-accent); }
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-insights__bar-fill { transition: none; }
|
||||
}
|
||||
.m-insights__bar-pct {
|
||||
font-family: var(--m-font-mono);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-c-ink);
|
||||
text-align: right;
|
||||
}
|
||||
.m-insights__bar-na {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ---- Resolved / unresolved tables ---- */
|
||||
.m-insights__table-wrap {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.m-insights__table-wrap--dim { background: var(--m-c-paper-2); opacity: 0.92; }
|
||||
.m-insights__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--m-font-body);
|
||||
}
|
||||
.m-insights__table thead th {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: var(--m-space-3) var(--m-space-3);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
color: var(--m-c-ink-soft);
|
||||
background: var(--m-c-paper-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-insights__table tbody td {
|
||||
padding: var(--m-space-3) var(--m-space-3);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
vertical-align: middle;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-insights__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.m-insights__row { transition: background 120ms ease; }
|
||||
.m-insights__row:hover { background: var(--m-c-paper-2); }
|
||||
.m-insights__row--hit { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
|
||||
.m-insights__row--miss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
|
||||
.m-insights__row--pending { box-shadow: inset 2px 0 0 0 var(--m-c-rule); }
|
||||
|
||||
.m-insights__sport {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-2);
|
||||
}
|
||||
.m-insights__sport-icon { --m-sport-size: 18px; }
|
||||
|
||||
.m-insights__verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--m-radius-xs);
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.m-insights__verdict--hit {
|
||||
color: var(--m-c-positive);
|
||||
background: rgba(21, 128, 61, 0.10);
|
||||
}
|
||||
.m-insights__verdict--miss {
|
||||
color: var(--m-c-anomaly);
|
||||
background: rgba(220, 38, 38, 0.10);
|
||||
}
|
||||
.m-insights__verdict--pending {
|
||||
color: var(--m-c-ink-soft);
|
||||
background: transparent;
|
||||
}
|
||||
[data-theme="dark"] .m-insights__verdict--hit {
|
||||
color: var(--m-c-positive);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
[data-theme="dark"] .m-insights__verdict--miss {
|
||||
color: var(--m-c-anomaly);
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
}
|
||||
|
||||
.m-insights__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--m-c-ink);
|
||||
border-bottom: 1px solid var(--m-c-accent);
|
||||
padding-bottom: 1px;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-insights__open:hover {
|
||||
color: var(--m-c-accent);
|
||||
border-bottom-color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
/* ---- Empty-state block (shared with feed) ---- */
|
||||
.m-list-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-7);
|
||||
text-align: center;
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
// Render kind for the breakdown grid — disambiguates how `Key` is shown.
|
||||
private enum BucketRenderKind
|
||||
{
|
||||
Severity,
|
||||
Sport,
|
||||
Score,
|
||||
}
|
||||
|
||||
private AnomalyInsightsVm? _vm;
|
||||
private bool _loading = true;
|
||||
private bool _errored;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
_loadCts = new CancellationTokenSource();
|
||||
var ct = _loadCts.Token;
|
||||
|
||||
_loading = true;
|
||||
_errored = false;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var report = await InsightsService.GetReportAsync(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_vm = report;
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded */ }
|
||||
catch
|
||||
{
|
||||
_errored = true;
|
||||
_vm = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAnomaly(MouseEventArgs e, Guid anomalyId)
|
||||
{
|
||||
Nav.NavigateTo("/anomalies/" + anomalyId.ToString());
|
||||
}
|
||||
|
||||
// ---- Bucket rendering ---------------------------------------------------
|
||||
|
||||
private RenderFragment RenderBucketTable(
|
||||
IReadOnlyList<OutcomeBucket> buckets,
|
||||
BucketRenderKind kind) => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "m-insights__buckets");
|
||||
builder.AddAttribute(2, "data-test", "insights-bucket-grid");
|
||||
|
||||
// Head row
|
||||
builder.OpenElement(10, "div");
|
||||
builder.AddAttribute(11, "class", "m-insights__bucket-head");
|
||||
|
||||
builder.OpenElement(12, "span");
|
||||
builder.AddContent(13, L["Insights.Column.Bucket"]);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(14, "span");
|
||||
builder.AddContent(15, L["Insights.Column.HitsOfTotal"]);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(16, "span");
|
||||
builder.AddContent(17, L["Insights.Column.HitRate"]);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
|
||||
// Data rows
|
||||
var seq = 100;
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
var local = bucket;
|
||||
var isEmpty = local.Total == 0;
|
||||
var rowClass = isEmpty
|
||||
? "m-insights__bucket-row m-insights__bucket-row--dim"
|
||||
: "m-insights__bucket-row";
|
||||
|
||||
builder.OpenElement(seq++, "div");
|
||||
builder.AddAttribute(seq++, "class", rowClass);
|
||||
builder.AddAttribute(seq++, "data-test", "insights-bucket-row");
|
||||
builder.AddAttribute(seq++, "data-bucket-key", local.Key);
|
||||
|
||||
// Label cell
|
||||
builder.OpenElement(seq++, "span");
|
||||
var labelClass = kind == BucketRenderKind.Score
|
||||
? "m-insights__bucket-label m-insights__bucket-label--mono"
|
||||
: "m-insights__bucket-label";
|
||||
builder.AddAttribute(seq++, "class", labelClass);
|
||||
builder.AddContent(seq++, RenderBucketLabel(local.Key, kind));
|
||||
builder.CloseElement();
|
||||
|
||||
// Counts cell
|
||||
builder.OpenElement(seq++, "span");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bucket-counts");
|
||||
builder.OpenElement(seq++, "strong");
|
||||
builder.AddContent(seq++, local.Hits.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
builder.CloseElement();
|
||||
builder.AddContent(seq++, " / " + local.Total.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
builder.CloseElement();
|
||||
|
||||
// Hit-rate cell — bar + percent, or N/A pill when empty
|
||||
builder.OpenElement(seq++, "div");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bar");
|
||||
|
||||
if (isEmpty || local.HitRate is null)
|
||||
{
|
||||
builder.OpenElement(seq++, "span");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bar-na");
|
||||
builder.AddContent(seq++, L["Insights.Bucket.NotApplicable"]);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(seq++, "span");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bar-pct");
|
||||
builder.AddAttribute(seq++, "style", "color: var(--m-c-ink-soft);");
|
||||
builder.AddContent(seq++, "—");
|
||||
builder.CloseElement();
|
||||
}
|
||||
else
|
||||
{
|
||||
var rate = local.HitRate.Value;
|
||||
var pct = (double)(rate * 100m);
|
||||
var pctClamped = Math.Max(0, Math.Min(100, pct));
|
||||
var tone = rate >= 0.60m ? "positive" : (rate < 0.40m ? "negative" : "neutral");
|
||||
|
||||
builder.OpenElement(seq++, "div");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bar-track");
|
||||
builder.AddAttribute(seq++, "role", "progressbar");
|
||||
builder.AddAttribute(seq++, "aria-valuemin", "0");
|
||||
builder.AddAttribute(seq++, "aria-valuemax", "100");
|
||||
builder.AddAttribute(seq++, "aria-valuenow", pctClamped.ToString("0", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
builder.OpenElement(seq++, "div");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bar-fill m-insights__bar-fill--" + tone);
|
||||
builder.AddAttribute(seq++, "style", "width: " + pctClamped.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + "%;");
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(seq++, "span");
|
||||
builder.AddAttribute(seq++, "class", "m-insights__bar-pct");
|
||||
builder.AddContent(seq++, ((int)Math.Round(pct, MidpointRounding.AwayFromZero)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%");
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
builder.CloseElement(); // .m-insights__bar
|
||||
builder.CloseElement(); // .m-insights__bucket-row
|
||||
}
|
||||
|
||||
builder.CloseElement(); // .m-insights__buckets
|
||||
};
|
||||
|
||||
private RenderFragment RenderBucketLabel(string key, BucketRenderKind kind) => builder =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case BucketRenderKind.Severity:
|
||||
{
|
||||
var locKey = key switch
|
||||
{
|
||||
OutcomeBucketKeys.SeverityHigh => "Anomaly.Severity.High",
|
||||
OutcomeBucketKeys.SeverityMedium => "Anomaly.Severity.Medium",
|
||||
OutcomeBucketKeys.SeverityLow => "Anomaly.Severity.Low",
|
||||
_ => "Anomaly.Severity.Low",
|
||||
};
|
||||
builder.AddContent(0, L[locKey]);
|
||||
break;
|
||||
}
|
||||
case BucketRenderKind.Sport:
|
||||
{
|
||||
var trimmed = key.StartsWith(OutcomeBucketKeys.SportPrefix, StringComparison.Ordinal)
|
||||
? key.Substring(OutcomeBucketKeys.SportPrefix.Length)
|
||||
: key;
|
||||
if (int.TryParse(trimmed, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var code))
|
||||
{
|
||||
var label = SportLabels.Resolve(L, code);
|
||||
builder.OpenComponent<SportIcon>(0);
|
||||
builder.AddAttribute(1, "Code", code);
|
||||
builder.AddAttribute(2, "Label", label);
|
||||
builder.AddAttribute(3, "ClassName", "m-insights__sport-icon");
|
||||
builder.CloseComponent();
|
||||
builder.AddContent(4, label);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AddContent(0, key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BucketRenderKind.Score:
|
||||
default:
|
||||
{
|
||||
var trimmed = key.StartsWith(OutcomeBucketKeys.BinPrefix, StringComparison.Ordinal)
|
||||
? key.Substring(OutcomeBucketKeys.BinPrefix.Length)
|
||||
: key;
|
||||
builder.AddContent(0, trimmed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Formatting / labels -----------------------------------------------
|
||||
|
||||
private static string HitRateTone(decimal? rate) => rate switch
|
||||
{
|
||||
null => "neutral",
|
||||
>= 0.60m => "positive",
|
||||
< 0.40m => "negative",
|
||||
_ => "neutral",
|
||||
};
|
||||
|
||||
private static string FormatPercent(decimal? rate)
|
||||
{
|
||||
if (rate is null) return "—";
|
||||
var pct = (int)Math.Round(rate.Value * 100m, MidpointRounding.AwayFromZero);
|
||||
return pct.ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
|
||||
}
|
||||
|
||||
private string SideLabel(Side? side) => side switch
|
||||
{
|
||||
Side.Side1 => L["Insights.Side.Side1"],
|
||||
Side.Side2 => L["Insights.Side.Side2"],
|
||||
Side.Draw => L["Insights.Side.Draw"],
|
||||
_ => L["Insights.Side.Unknown"],
|
||||
};
|
||||
|
||||
private string OutcomeLabel(AnomalyOutcomeKind o) => o switch
|
||||
{
|
||||
AnomalyOutcomeKind.Hit => L["Insights.Outcome.Hit"],
|
||||
AnomalyOutcomeKind.Miss => L["Insights.Outcome.Miss"],
|
||||
AnomalyOutcomeKind.Unresolved => L["Insights.Outcome.Unresolved"],
|
||||
_ => L["Insights.Outcome.Unresolved"],
|
||||
};
|
||||
|
||||
private static string OutcomeCss(AnomalyOutcomeKind o) => o switch
|
||||
{
|
||||
AnomalyOutcomeKind.Hit => "hit",
|
||||
AnomalyOutcomeKind.Miss => "miss",
|
||||
AnomalyOutcomeKind.Unresolved => "pending",
|
||||
_ => "pending",
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
_loadCts?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user