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
@@ -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>