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:
2026-05-05 13:39:39 +03:00
parent a6ff368015
commit 12208a4762
27 changed files with 2273 additions and 32 deletions
@@ -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);
}
}
@@ -0,0 +1,244 @@
@*
AnomalyEvidence — two-column "before / after" presentation of the parsed
EvidenceJson from an anomaly. Each column shows the snapshot timestamp,
the implied probability per side (with a horizontal bar), and the raw
rate (mono numerals).
The favourite-swap is called out via a single-line statement above the
columns. Tennis (2-way) markets render with no Draw row — handled by
nullable PDraw / RateDraw on the snapshot.
Pure presentation — no data fetching. Callers shape an `AnomalyDetailVm`
and pass `Pre` + `Post` snapshots in.
*@
@inject IStringLocalizer<SharedResource> L
<div class="m-evidence" data-test="anomaly-evidence">
<div class="m-evidence__summary">
<div class="m-evidence__gap">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Anomaly.Evidence.SuspensionDuration"]
</span>
<span class="m-evidence__gap-value m-mono">@FormatGap(SuspensionGapSeconds)</span>
</div>
@if (Pre.Favourite != Post.Favourite && Pre.Favourite != AnomalyFavourite.None && Post.Favourite != AnomalyFavourite.None)
{
<div class="m-evidence__swap" data-test="favourite-swap">
<span class="m-anomaly__pulse" style="background: var(--m-c-anomaly);"></span>
<span class="m-evidence__swap-text">
@L["Anomaly.Evidence.FavouriteSwap"] ·
<strong>@FavLabel(Pre.Favourite) → @FavLabel(Post.Favourite)</strong>
</span>
</div>
}
</div>
<div class="m-evidence__columns">
<article class="m-evidence__col">
<header class="m-evidence__col-head">
<span class="m-kicker">@L["Anomaly.Evidence.Pre"]</span>
<time class="m-mono" datetime="@Pre.CapturedAt.ToString("o")">
@Pre.CapturedAt.ToString("dd MMM HH:mm:ss")
</time>
</header>
@RenderRow(L["Detail.Chart.Win1"], Pre.P1, Pre.Rate1, IsFavourite(Pre, AnomalyFavourite.Side1))
@if (!IsTwoWay)
{
@RenderRow(L["Detail.Chart.Draw"], Pre.PDraw, Pre.RateDraw, IsFavourite(Pre, AnomalyFavourite.Draw))
}
@RenderRow(L["Detail.Chart.Win2"], Pre.P2, Pre.Rate2, IsFavourite(Pre, AnomalyFavourite.Side2))
</article>
<div class="m-evidence__arrow" aria-hidden="true">→</div>
<article class="m-evidence__col m-evidence__col--post">
<header class="m-evidence__col-head">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Anomaly.Evidence.Post"]
</span>
<time class="m-mono" datetime="@Post.CapturedAt.ToString("o")">
@Post.CapturedAt.ToString("dd MMM HH:mm:ss")
</time>
</header>
@RenderRow(L["Detail.Chart.Win1"], Post.P1, Post.Rate1, IsFavourite(Post, AnomalyFavourite.Side1))
@if (!IsTwoWay)
{
@RenderRow(L["Detail.Chart.Draw"], Post.PDraw, Post.RateDraw, IsFavourite(Post, AnomalyFavourite.Draw))
}
@RenderRow(L["Detail.Chart.Win2"], Post.P2, Post.Rate2, IsFavourite(Post, AnomalyFavourite.Side2))
</article>
</div>
</div>
<style>
.m-evidence {
display: grid;
gap: var(--m-space-4);
}
.m-evidence__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--m-space-4);
flex-wrap: wrap;
}
.m-evidence__gap {
display: flex;
flex-direction: column;
gap: 6px;
}
.m-evidence__gap-value {
font-size: 1.5rem;
font-weight: 500;
color: var(--m-c-ink);
}
.m-evidence__swap {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: color-mix(in srgb, var(--m-c-anomaly) 8%, transparent);
border: 1px solid var(--m-c-anomaly);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.75rem;
}
.m-evidence__swap-text strong { font-weight: 600; }
.m-evidence__columns {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--m-space-4);
align-items: stretch;
}
@@media (max-width: 720px) {
.m-evidence__columns { grid-template-columns: 1fr; }
.m-evidence__arrow { display: none; }
}
.m-evidence__col {
display: grid;
gap: var(--m-space-3);
padding: var(--m-space-4);
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
}
.m-evidence__col--post {
border-left: 3px solid var(--m-c-anomaly);
}
.m-evidence__col-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--m-space-3);
flex-wrap: wrap;
}
.m-evidence__col-head time {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-evidence__arrow {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--m-font-display);
font-size: 1.75rem;
color: var(--m-c-accent);
}
.m-evidence__row {
display: grid;
grid-template-columns: 60px minmax(0, 1fr) 56px;
gap: var(--m-space-3);
align-items: center;
}
.m-evidence__row-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-c-ink-soft);
}
.m-evidence__row.is-favourite .m-evidence__row-label {
color: var(--m-c-ink);
font-weight: 600;
}
.m-evidence__bar {
height: 8px;
background: var(--m-c-rule);
position: relative;
overflow: hidden;
border-radius: 1px;
}
.m-evidence__bar-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--m-c-ink-soft);
transition: width 240ms ease;
}
.m-evidence__col--post .m-evidence__bar-fill { background: var(--m-c-anomaly); }
.m-evidence__row.is-favourite .m-evidence__bar-fill { background: var(--m-c-accent); }
.m-evidence__col--post .m-evidence__row.is-favourite .m-evidence__bar-fill {
background: var(--m-c-anomaly);
}
.m-evidence__rate {
text-align: right;
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-weight: 500;
}
</style>
@code {
[Parameter, EditorRequired] public AnomalyEvidenceSnapshot Pre { get; set; } = default!;
[Parameter, EditorRequired] public AnomalyEvidenceSnapshot Post { get; set; } = default!;
[Parameter] public int SuspensionGapSeconds { get; set; }
[Parameter] public bool IsTwoWay { get; set; }
private RenderFragment RenderRow(string label, decimal? probability, decimal? rate, bool isFavourite) => builder =>
{
var pct = (probability ?? 0m) * 100m;
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", isFavourite ? "m-evidence__row is-favourite" : "m-evidence__row");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", "m-evidence__row-label");
builder.AddContent(4, label);
builder.CloseElement();
builder.OpenElement(5, "div");
builder.AddAttribute(6, "class", "m-evidence__bar");
builder.AddAttribute(7, "role", "img");
builder.AddAttribute(8, "aria-label", string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0}%", pct));
builder.OpenElement(9, "span");
builder.AddAttribute(10, "class", "m-evidence__bar-fill");
builder.AddAttribute(11, "style", string.Format(System.Globalization.CultureInfo.InvariantCulture, "width: {0:0.0}%;", Math.Clamp(pct, 0m, 100m)));
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(12, "span");
builder.AddAttribute(13, "class", "m-evidence__rate");
builder.AddContent(14, rate is { } r ? r.ToString("0.00") : "—");
builder.CloseElement();
builder.CloseElement();
};
private static bool IsFavourite(AnomalyEvidenceSnapshot s, AnomalyFavourite side) => s.Favourite == side;
private string FavLabel(AnomalyFavourite f) => f switch
{
AnomalyFavourite.Side1 => L["Detail.Chart.Win1"],
AnomalyFavourite.Draw => L["Detail.Chart.Draw"],
AnomalyFavourite.Side2 => L["Detail.Chart.Win2"],
_ => "—",
};
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);
}
}
+44
View File
@@ -1,4 +1,6 @@
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject AnomalyBrowsingState AnomalyState
<nav class="m-nav" aria-label="primary">
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
@@ -26,6 +28,12 @@
<NavLink class="m-nav__link" href="anomalies">
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
<span>@L["Nav.Anomalies"]</span>
@if (AnomalyState.UnreadCount > 0)
{
<span class="m-nav__badge" data-test="anomaly-badge" aria-label="@L["Anomaly.Nav.UnreadAria"]">
@AnomalyState.UnreadCount
</span>
}
</NavLink>
<NavLink class="m-nav__link" href="results">
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
@@ -38,3 +46,39 @@
<span>@L["Nav.Settings"]</span>
</NavLink>
</nav>
<style>
.m-nav__badge {
margin-left: auto;
min-width: 18px;
padding: 0 6px;
background: var(--m-c-anomaly);
color: #ffffff;
font-family: var(--m-font-mono);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.05em;
line-height: 18px;
height: 18px;
text-align: center;
border-radius: var(--m-radius-xs);
animation: m-pulse 1.6s ease-in-out infinite;
}
@@media (prefers-reduced-motion: reduce) {
.m-nav__badge { animation: none; }
}
</style>
@code {
protected override void OnInitialized()
{
AnomalyState.OnChange += OnAnomalyStateChanged;
}
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose()
{
AnomalyState.OnChange -= OnAnomalyStateChanged;
}
}
@@ -0,0 +1,94 @@
@*
SeverityBadge — small uppercase pill encoding an anomaly's severity bucket.
The High variant is signal-red (`--m-c-anomaly`) and pulses to draw the eye
on the feed page. Medium uses the editorial amber accent. Low is a muted
neutral so it does not compete with higher severities.
The component is presentational only — callers compute the severity (via
`AnomalySeverityRules.FromScore`) and pass it in.
*@
@inject IStringLocalizer<SharedResource> L
<span class="m-severity m-severity--@_classKey @AdditionalClass" data-severity="@Severity" data-test="severity-badge">
@if (ShowDot)
{
<span class="m-severity__dot" aria-hidden="true"></span>
}
<span class="m-severity__label">@Label</span>
@if (ShowScore && Score is { } s)
{
<span class="m-severity__score m-mono" aria-hidden="true">@s.ToString("0.00")</span>
}
</span>
<style>
.m-severity {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.16em;
border-radius: var(--m-radius-xs);
border: 1px solid currentColor;
white-space: nowrap;
}
.m-severity__dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
flex: 0 0 auto;
}
.m-severity__score {
font-feature-settings: var(--m-num-feature);
opacity: 0.78;
font-size: 0.625rem;
letter-spacing: 0.08em;
}
.m-severity--low {
color: var(--m-c-ink-soft);
background: color-mix(in srgb, var(--m-c-ink-soft) 8%, transparent);
}
.m-severity--medium {
color: var(--m-c-accent);
background: color-mix(in srgb, var(--m-c-accent) 12%, transparent);
}
.m-severity--high {
color: var(--m-c-anomaly);
background: color-mix(in srgb, var(--m-c-anomaly) 12%, transparent);
}
.m-severity--high .m-severity__dot {
animation: m-pulse 1.6s ease-in-out infinite;
}
@@media (prefers-reduced-motion: reduce) {
.m-severity--high .m-severity__dot { animation: none; opacity: 1; }
}
</style>
@code {
[Parameter, EditorRequired] public AnomalySeverity Severity { get; set; }
[Parameter] public decimal? Score { get; set; }
[Parameter] public bool ShowScore { get; set; } = true;
[Parameter] public bool ShowDot { get; set; } = true;
[Parameter] public string? AdditionalClass { get; set; }
private string _classKey => Severity switch
{
AnomalySeverity.High => "high",
AnomalySeverity.Medium => "medium",
_ => "low",
};
private string Label => Severity switch
{
AnomalySeverity.High => L["Anomaly.Severity.High"],
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
_ => L["Anomaly.Severity.Low"],
};
}
-5
View File
@@ -1,5 +0,0 @@
@page "/anomalies"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Anomalies"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Anomalies"]" />
@@ -0,0 +1,304 @@
@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>
</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))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
}
}
private void MarkAllRead()
{
State.MarkAllSeen(DateTimeOffset.UtcNow);
}
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) => 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 FormatDate(DateTimeOffset? value)
=> value?.ToString("yyyy-MM-dd") ?? string.Empty;
public void Dispose()
{
State.OnChange -= OnStateChanged;
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}
@@ -0,0 +1,113 @@
@page "/anomalies/{Id:guid}"
@using Marathon.UI.Components
@inject IStringLocalizer<SharedResource> L
@inject IAnomalyBrowsingService Anomalies
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Anomaly.Title"]</PageTitle>
<section class="m-shell">
@if (_loading && _detail is null)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_detail is null)
{
<div class="m-list-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">404</span>
<p style="color: var(--m-c-ink-soft);">@L["Anomaly.Detail.NotFound"]</p>
<MudButton Variant="Variant.Outlined" OnClick='() => Nav.NavigateTo("/anomalies")'>
@L["Anomaly.Detail.BackToFeed"]
</MudButton>
</div>
}
else
{
<header class="m-detail-header m-rise m-rise-1">
<div class="m-detail-header__lockup">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@KindLabel(_detail.Item.Kind) · @_detail.Item.CountryCode · @_detail.Item.LeagueId
</span>
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem); margin-top: var(--m-space-2);">
@_detail.Item.EventTitle
</h1>
<div class="m-mono" style="margin-top: var(--m-space-2); color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.75rem;">
@L["Anomaly.Card.DetectedAt"] @_detail.Item.DetectedAt.ToString("dd MMM yyyy · HH:mm:ss") · MSK
</div>
</div>
<aside class="m-detail-header__odds">
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Anomaly.Card.Score"]</span>
<SeverityBadge Severity="_detail.Item.Severity" Score="_detail.Item.Score" />
</div>
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Anomaly.Card.GapSeconds"]</span>
<span class="m-mono" data-test="suspension-duration">@FormatGap(_detail.Item.SuspensionGapSeconds)</span>
</div>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.OpenInNew"
OnClick="@(() => Nav.NavigateTo($"/events/{Uri.EscapeDataString(_detail.Item.EventId.Value)}"))"
Class="m-detail-header__export"
data-test="link-back-to-event">
@L["Anomaly.Detail.LinkBackToEvent"]
</MudButton>
</aside>
</header>
<hr class="m-rule" />
<article class="m-card m-card--anomaly m-rise m-rise-2">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Anomaly.Detail.EvidenceTitle"]
</span>
<div style="margin-top: var(--m-space-4);">
<AnomalyEvidence Pre="_detail.Pre"
Post="_detail.Post"
SuspensionGapSeconds="_detail.Item.SuspensionGapSeconds"
IsTwoWay="_detail.Item.IsTwoWay" />
</div>
</article>
}
</section>
@code {
[Parameter] public Guid Id { get; set; }
private AnomalyDetailVm? _detail;
private bool _loading = true;
protected override async Task OnParametersSetAsync()
{
_loading = true;
try
{
_detail = await Anomalies.GetByIdAsync(Id, CancellationToken.None);
}
catch
{
_detail = null;
}
finally
{
_loading = false;
}
}
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
_ => kind.ToString(),
};
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);
}
}
+4
View File
@@ -98,6 +98,9 @@
<Field Label="@L["Settings.Workers.ResultsPollIntervalSeconds"]">
<MudNumericField T="int" @bind-Value="_workers.ResultsPollIntervalSeconds" Min="60" Max="7200" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Workers.AnomalyDetectionEnabled"]" Hint="@L["Settings.Workers.AnomalyDetectionEnabled.Hint"]">
<MudSwitch T="bool" @bind-Value="_workers.AnomalyDetectionEnabled" Color="Color.Primary" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
</div>
@@ -197,6 +200,7 @@
LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds,
ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled,
ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds,
AnomalyDetectionEnabled = WorkerOpts.CurrentValue.AnomalyDetectionEnabled,
};
_storage = new StorageOptions
@@ -119,6 +119,8 @@
<data name="Settings.Workers.ResultsPollerEnabled"><value>Results poller enabled</value></data>
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Disabled until Phase 8. Enable only after match-complete polling is implemented.</value></data>
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Results poll interval (sec)</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled"><value>Anomaly detection enabled</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled.Hint"><value>Runs the suspension-flip detector on every cycle. Disable to pause analysis without losing collected snapshots.</value></data>
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
@@ -150,6 +152,37 @@
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 7 — Anomaly feed UI -->
<data name="Anomaly.Title"><value>Anomaly feed</value></data>
<data name="Anomaly.Lede"><value>Real-time signal log of suspension-flip events. The detector runs every cycle, computes implied probabilities before and after each market freeze, and surfaces flips ranked by confidence.</value></data>
<data name="Anomaly.Severity.Low"><value>Low</value></data>
<data name="Anomaly.Severity.Medium"><value>Medium</value></data>
<data name="Anomaly.Severity.High"><value>High</value></data>
<data name="Anomaly.Filter.AnySeverity"><value>Any</value></data>
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
<data name="Anomaly.Filter.Sport"><value>Sport</value></data>
<data name="Anomaly.Filter.From"><value>Detected from</value></data>
<data name="Anomaly.Filter.To"><value>Detected to</value></data>
<data name="Anomaly.Filter.DateRange"><value>Date range</value></data>
<data name="Anomaly.Filter.MarkRead"><value>Mark all read</value></data>
<data name="Anomaly.Card.DetectedAt"><value>Detected</value></data>
<data name="Anomaly.Card.Score"><value>Confidence</value></data>
<data name="Anomaly.Card.Kind"><value>Kind</value></data>
<data name="Anomaly.Card.GapSeconds"><value>Suspension gap</value></data>
<data name="Anomaly.Evidence.Pre"><value>Before suspension</value></data>
<data name="Anomaly.Evidence.Post"><value>After suspension</value></data>
<data name="Anomaly.Evidence.Probability"><value>Implied prob.</value></data>
<data name="Anomaly.Evidence.Rate"><value>Rate</value></data>
<data name="Anomaly.Evidence.SuspensionDuration"><value>Suspension duration</value></data>
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
<data name="Anomaly.Stat.Total"><value>Total</value></data>
<data name="Anomaly.Nav.UnreadAria"><value>Unread anomalies</value></data>
<!-- Phase 6 — Pre-match list / Live list / Detail / Export -->
<data name="PreMatch.Title"><value>Pre-match schedule</value></data>
<data name="PreMatch.Lede"><value>Upcoming events with their latest pre-match Win-1 / Draw / Win-2 odds preview. Filter by sport, country, league, or team.</value></data>
@@ -125,6 +125,8 @@
<data name="Settings.Workers.ResultsPollerEnabled"><value>Сборщик результатов включён</value></data>
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Отключён до Phase 8. Включите только после реализации опроса match-complete.</value></data>
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Интервал сборщика результатов (сек)</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled"><value>Детектор аномалий включён</value></data>
<data name="Settings.Workers.AnomalyDetectionEnabled.Hint"><value>Запускает детектор разворота после паузы на каждом цикле. Отключение приостанавливает анализ без потери накопленных снимков.</value></data>
<!-- Settings — Storage -->
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
@@ -163,6 +165,37 @@
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 7 — Лента аномалий -->
<data name="Anomaly.Title"><value>Лента аномалий</value></data>
<data name="Anomaly.Lede"><value>Сигнальный журнал «разворотов» в реальном времени. Детектор проходит каждый цикл, считает подразумеваемые вероятности до и после каждой заморозки рынка и ранжирует находки по уверенности.</value></data>
<data name="Anomaly.Severity.Low"><value>Низкая</value></data>
<data name="Anomaly.Severity.Medium"><value>Средняя</value></data>
<data name="Anomaly.Severity.High"><value>Высокая</value></data>
<data name="Anomaly.Filter.AnySeverity"><value>Любая</value></data>
<data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data>
<data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data>
<data name="Anomaly.Filter.From"><value>Обнаружено с</value></data>
<data name="Anomaly.Filter.To"><value>Обнаружено по</value></data>
<data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data>
<data name="Anomaly.Filter.MarkRead"><value>Отметить прочитанными</value></data>
<data name="Anomaly.Card.DetectedAt"><value>Обнаружено</value></data>
<data name="Anomaly.Card.Score"><value>Уверенность</value></data>
<data name="Anomaly.Card.Kind"><value>Тип</value></data>
<data name="Anomaly.Card.GapSeconds"><value>Длительность паузы</value></data>
<data name="Anomaly.Evidence.Pre"><value>До паузы</value></data>
<data name="Anomaly.Evidence.Post"><value>После паузы</value></data>
<data name="Anomaly.Evidence.Probability"><value>Подразум. вер.</value></data>
<data name="Anomaly.Evidence.Rate"><value>Кэф</value></data>
<data name="Anomaly.Evidence.SuspensionDuration"><value>Длительность паузы</value></data>
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
<data name="Anomaly.Stat.Total"><value>Всего</value></data>
<data name="Anomaly.Nav.UnreadAria"><value>Непрочитанные аномалии</value></data>
<!-- Phase 6 — Список матчей / Лайв / Детали / Экспорт -->
<data name="PreMatch.Title"><value>Расписание до матча</value></data>
<data name="PreMatch.Lede"><value>Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде.</value></data>
@@ -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; }
}
}
@@ -0,0 +1,59 @@
namespace Marathon.UI.Services;
/// <summary>
/// Singleton (per RCL) anomaly feed state — current filter, last-seen
/// timestamp for the unread-badge, and a cached unread count.
/// Pages produce new <see cref="AnomalyFilter"/> instances and call
/// <see cref="UpdateFilter"/>; the OnChange event re-renders subscribers.
/// </summary>
/// <remarks>
/// LastSeenUtc is held in memory; the host can persist it through the standard
/// settings writer if desired. It seeds to <c>DateTimeOffset.MinValue</c> so
/// the first session shows every anomaly as unread.
/// </remarks>
public sealed class AnomalyBrowsingState
{
private AnomalyFilter _filter = new();
private DateTimeOffset _lastSeenUtc = DateTimeOffset.MinValue;
private int _unreadCount;
public AnomalyFilter Filter => _filter;
public DateTimeOffset LastSeenUtc => _lastSeenUtc;
public int UnreadCount => _unreadCount;
public event Action? OnChange;
public void UpdateFilter(AnomalyFilter next)
{
ArgumentNullException.ThrowIfNull(next);
if (Equals(_filter, next)) return;
_filter = next;
OnChange?.Invoke();
}
public void MarkAllSeen(DateTimeOffset utcNow)
{
if (_lastSeenUtc == utcNow && _unreadCount == 0) return;
_lastSeenUtc = utcNow;
_unreadCount = 0;
OnChange?.Invoke();
}
public void SetUnreadCount(int count)
{
var clamped = count < 0 ? 0 : count;
if (_unreadCount == clamped) return;
_unreadCount = clamped;
OnChange?.Invoke();
}
public void SeedLastSeen(DateTimeOffset value)
{
// Used by hosts that persist the last-seen timestamp across restarts.
if (_lastSeenUtc == value) return;
_lastSeenUtc = value;
OnChange?.Invoke();
}
}
@@ -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;
}
}
@@ -0,0 +1,22 @@
namespace Marathon.UI.Services;
/// <summary>
/// Read-only browsing facade over the Anomaly + Event repositories. Pages
/// depend on this — never on <c>IAnomalyRepository</c> directly — so view-model
/// shaping (severity buckets, evidence parsing, event metadata join) stays in
/// one place.
/// </summary>
public interface IAnomalyBrowsingService
{
/// <summary>List anomalies matching <paramref name="filter"/>, newest first.</summary>
Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct);
/// <summary>Single anomaly aggregate for the detail page; null when not found.</summary>
Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct);
/// <summary>Count of anomalies detected after <paramref name="since"/> — drives the nav badge.</summary>
Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct);
/// <summary>The set of distinct sport codes present in the anomaly feed — used to populate filter chips.</summary>
Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct);
}
@@ -46,9 +46,11 @@ public static class UiServicesExtensions
services.AddSingleton<ThemeState>();
services.AddSingleton<LocaleState>();
services.AddSingleton<EventBrowsingState>();
services.AddSingleton<AnomalyBrowsingState>();
// Browsing facade — Scoped so it captures the per-circuit repository scope.
// Browsing facades — Scoped so they capture the per-circuit repository scope.
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -34,4 +34,11 @@ public sealed class WorkerOptions
/// Default: 300 s (5 minutes).
/// </summary>
public int ResultsPollIntervalSeconds { get; set; } = 300;
/// <summary>
/// Whether the anomaly-detection poller should run.
/// Default: <c>true</c> — this is the product's primary differentiator
/// (Phase 7) and should be enabled by default.
/// </summary>
public bool AnomalyDetectionEnabled { get; set; } = true;
}