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,249 @@
|
||||
@*
|
||||
AnomalyCard — single row in the anomaly feed.
|
||||
|
||||
Asymmetric layout: severity badge at top-right, sport icon + event title
|
||||
at top-left, a compact pre→post odds strip in the middle, the detected-at
|
||||
timestamp at the bottom. Whole card is clickable / Enter/Space-keyable to
|
||||
navigate to /anomalies/{id}.
|
||||
|
||||
Visual tone shifts with severity:
|
||||
- High: signal-red left border + paper-2 background.
|
||||
- Medium: amber left border.
|
||||
- Low: muted neutral border.
|
||||
*@
|
||||
|
||||
@using Marathon.UI.Components
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<article class="m-anomaly-card m-anomaly-card--@_severityClass m-rise"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
data-test="anomaly-card"
|
||||
data-anomaly-id="@Item.Id"
|
||||
@onclick="HandleClick"
|
||||
@onkeydown="HandleKey">
|
||||
<header class="m-anomaly-card__head">
|
||||
<div class="m-anomaly-card__lockup">
|
||||
<SportIcon Code="@Item.Sport.Value" Label="@SportLabel(Item.Sport.Value)" ClassName="m-anomaly-card__sport" />
|
||||
<div class="m-anomaly-card__title-block">
|
||||
<span class="m-kicker">@KindLabel(Item.Kind) · @Item.CountryCode · @Item.LeagueId</span>
|
||||
<h3 class="m-anomaly-card__title">@Item.EventTitle</h3>
|
||||
</div>
|
||||
</div>
|
||||
<SeverityBadge Severity="Item.Severity" Score="Item.Score" />
|
||||
</header>
|
||||
|
||||
<div class="m-anomaly-card__strip" aria-hidden="true">
|
||||
@RenderRateCell(L["Detail.Chart.Win1"], Item.PreWin1Rate, Item.PostWin1Rate)
|
||||
@if (!Item.IsTwoWay)
|
||||
{
|
||||
@RenderRateCell(L["Detail.Chart.Draw"], Item.PreDrawRate, Item.PostDrawRate)
|
||||
}
|
||||
@RenderRateCell(L["Detail.Chart.Win2"], Item.PreWin2Rate, Item.PostWin2Rate)
|
||||
</div>
|
||||
|
||||
<footer class="m-anomaly-card__foot">
|
||||
<span class="m-mono m-anomaly-card__time">
|
||||
<span class="m-anomaly-card__time-label">@L["Anomaly.Card.DetectedAt"]</span>
|
||||
<time datetime="@Item.DetectedAt.ToString("o")" title="@Item.DetectedAt.ToString("dd MMM yyyy HH:mm:ss")">
|
||||
@FormatRelative(Item.DetectedAt)
|
||||
</time>
|
||||
</span>
|
||||
<span class="m-mono m-anomaly-card__gap">
|
||||
@L["Anomaly.Card.GapSeconds"] · @FormatGap(Item.SuspensionGapSeconds)
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.m-anomaly-card {
|
||||
display: grid;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-left: 3px solid var(--m-c-rule);
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.m-anomaly-card:hover {
|
||||
background: var(--m-c-paper-2);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.m-anomaly-card:focus-visible {
|
||||
outline: 2px solid var(--m-c-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.m-anomaly-card--high { border-left-color: var(--m-c-anomaly); }
|
||||
.m-anomaly-card--medium { border-left-color: var(--m-c-accent); }
|
||||
.m-anomaly-card--low { border-left-color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-anomaly-card__head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--m-space-3);
|
||||
align-items: start;
|
||||
}
|
||||
.m-anomaly-card__lockup {
|
||||
display: flex;
|
||||
gap: var(--m-space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.m-anomaly-card__sport {
|
||||
--m-sport-size: 22px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.m-anomaly-card__title-block { display: grid; gap: 4px; min-width: 0; }
|
||||
.m-anomaly-card__title {
|
||||
margin: 0;
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25;
|
||||
color: var(--m-c-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m-anomaly-card__strip {
|
||||
display: flex;
|
||||
gap: var(--m-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--m-space-3);
|
||||
background: var(--m-c-paper-2);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-anomaly-card__rate {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-family: var(--m-font-mono);
|
||||
}
|
||||
.m-anomaly-card__rate-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-anomaly-card__rate-pre {
|
||||
color: var(--m-c-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.m-anomaly-card__rate-arrow { color: var(--m-c-accent); font-size: 0.875rem; }
|
||||
.m-anomaly-card__rate-post {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.m-anomaly-card--high .m-anomaly-card__rate-post {
|
||||
color: var(--m-c-anomaly);
|
||||
}
|
||||
|
||||
.m-anomaly-card__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.m-anomaly-card__time-label { margin-right: 6px; opacity: 0.7; }
|
||||
.m-anomaly-card__time time { color: var(--m-c-ink); font-weight: 500; }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public AnomalyListItem Item { get; set; } = default!;
|
||||
[Parameter] public EventCallback<AnomalyListItem> OnClick { get; set; }
|
||||
|
||||
private string _severityClass => Item.Severity switch
|
||||
{
|
||||
AnomalySeverity.High => "high",
|
||||
AnomalySeverity.Medium => "medium",
|
||||
_ => "low",
|
||||
};
|
||||
|
||||
private RenderFragment RenderRateCell(string label, decimal? pre, decimal? post) => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddAttribute(1, "class", "m-anomaly-card__rate");
|
||||
|
||||
builder.OpenElement(2, "span");
|
||||
builder.AddAttribute(3, "class", "m-anomaly-card__rate-label");
|
||||
builder.AddContent(4, label);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(5, "span");
|
||||
builder.AddAttribute(6, "class", "m-anomaly-card__rate-pre");
|
||||
builder.AddContent(7, FormatRate(pre));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(8, "span");
|
||||
builder.AddAttribute(9, "class", "m-anomaly-card__rate-arrow");
|
||||
builder.AddContent(10, "→");
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(11, "span");
|
||||
builder.AddAttribute(12, "class", "m-anomaly-card__rate-post");
|
||||
builder.AddContent(13, FormatRate(post));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
};
|
||||
|
||||
private async Task HandleClick()
|
||||
{
|
||||
if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item);
|
||||
}
|
||||
|
||||
private async Task HandleKey(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" || e.Key == " ")
|
||||
{
|
||||
if (OnClick.HasDelegate) await OnClick.InvokeAsync(Item);
|
||||
}
|
||||
}
|
||||
|
||||
private string KindLabel(AnomalyKind kind) => kind switch
|
||||
{
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
private string SportLabel(int code) => code switch
|
||||
{
|
||||
6 => L["Sport.Basketball"],
|
||||
11 => L["Sport.Football"],
|
||||
22723 => L["Sport.Tennis"],
|
||||
43658 => L["Sport.Hockey"],
|
||||
_ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code),
|
||||
};
|
||||
|
||||
private static string FormatRate(decimal? r) => r is { } v
|
||||
? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "—";
|
||||
|
||||
private static string FormatGap(int seconds)
|
||||
{
|
||||
if (seconds <= 0) return "—";
|
||||
var ts = TimeSpan.FromSeconds(seconds);
|
||||
if (ts.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s", (int)ts.TotalSeconds);
|
||||
if (ts.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m{1:00}s", (int)ts.TotalMinutes, ts.Seconds);
|
||||
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h{1:00}m", (int)ts.TotalHours, ts.Minutes);
|
||||
}
|
||||
|
||||
private static string FormatRelative(DateTimeOffset value)
|
||||
{
|
||||
var delta = DateTimeOffset.UtcNow - value;
|
||||
if (delta < TimeSpan.Zero) delta = TimeSpan.Zero;
|
||||
if (delta.TotalSeconds < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}s ago", (int)delta.TotalSeconds);
|
||||
if (delta.TotalMinutes < 60) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}m ago", (int)delta.TotalMinutes);
|
||||
if (delta.TotalHours < 24) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}h ago", (int)delta.TotalHours);
|
||||
return value.ToString("dd MMM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user