From 250a93e7187025088698a39c7905f9a6371f8956 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 28 May 2026 22:34:28 +0300 Subject: [PATCH] feat(ui): live dashboard, capture-status pill, bet/backtest UX - Add IDashboardSummaryService/DashboardSummaryService: real event/snapshot/ anomaly counts, top-5 signals, and per-stage pipeline health from worker state. - Home: replace hard-coded zeros + placeholder feed with live data, a clickable signal feed, and a first-run empty state with a Settings CTA. - MainLayout: add an appbar capture-status pill (Capturing/Paused) bound to the poller toggles, refreshed via IOptionsMonitor.OnChange. - MyBets: success snackbar on bet submit. Backtest: surface a Cancel button while a run is in flight. - Add en/ru localization for all new strings; register IOptionsMonitor in the bUnit test context for layout-rendering tests. --- src/Marathon.UI/MainLayout.razor | 17 +++ .../Pages/Anomalies/Backtest.razor | 11 ++ src/Marathon.UI/Pages/Home.razor | 138 +++++++++++++----- src/Marathon.UI/Pages/MyBets/Journal.razor | 1 + .../Resources/SharedResource.en.resx | 10 ++ .../Resources/SharedResource.ru.resx | 10 ++ .../Services/DashboardSummaryService.cs | 74 ++++++++++ .../Services/IDashboardSummaryService.cs | 45 ++++++ .../Services/UiServicesExtensions.cs | 1 + .../Support/MarathonTestContext.cs | 6 + 10 files changed, 278 insertions(+), 35 deletions(-) create mode 100644 src/Marathon.UI/Services/DashboardSummaryService.cs create mode 100644 src/Marathon.UI/Services/IDashboardSummaryService.cs diff --git a/src/Marathon.UI/MainLayout.razor b/src/Marathon.UI/MainLayout.razor index eb554fb..a28f8fd 100644 --- a/src/Marathon.UI/MainLayout.razor +++ b/src/Marathon.UI/MainLayout.razor @@ -3,6 +3,7 @@ @inject ThemeState ThemeState @inject LocaleState LocaleState @inject IStringLocalizer L +@inject IOptionsMonitor Workers @@ -24,6 +25,12 @@
+ + + @(Capturing ? L["Scraping.On"] : L["Scraping.Off"]) +
@@ -123,11 +130,20 @@ @code { private bool _drawerOpen = true; private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build(); + private IDisposable? _workerOptionsListener; + + // "Capturing" when any of the primary pollers is enabled in config. + private bool Capturing => + Workers.CurrentValue.LivePollerEnabled + || Workers.CurrentValue.UpcomingPollerEnabled + || Workers.CurrentValue.AnomalyDetectionEnabled; protected override void OnInitialized() { ThemeState.OnChange += StateHasChanged; LocaleState.OnChange += StateHasChanged; + // Reflect Settings toggles live without requiring a navigation. + _workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged)); } private void ToggleDrawer() => _drawerOpen = !_drawerOpen; @@ -136,5 +152,6 @@ { ThemeState.OnChange -= StateHasChanged; LocaleState.OnChange -= StateHasChanged; + _workerOptionsListener?.Dispose(); } } diff --git a/src/Marathon.UI/Pages/Anomalies/Backtest.razor b/src/Marathon.UI/Pages/Anomalies/Backtest.razor index 81fb91d..9b81c55 100644 --- a/src/Marathon.UI/Pages/Anomalies/Backtest.razor +++ b/src/Marathon.UI/Pages/Anomalies/Backtest.razor @@ -127,6 +127,15 @@ @(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"]) + @if (_running) + { + + } @@ -650,6 +659,8 @@ } } + private void CancelRun() => _runCts?.Cancel(); + private void OnStakeRuleChanged(StakeRule next) { _form.StakeRule = next; diff --git a/src/Marathon.UI/Pages/Home.razor b/src/Marathon.UI/Pages/Home.razor index 95bd793..b4ff3d5 100644 --- a/src/Marathon.UI/Pages/Home.razor +++ b/src/Marathon.UI/Pages/Home.razor @@ -1,5 +1,7 @@ @page "/" @inject IStringLocalizer L +@inject IDashboardSummaryService Dashboard +@inject ILogger Logger @L["App.Title"] · @L["Nav.Dashboard"] @@ -15,10 +17,13 @@
- - - - + + + +
@@ -30,49 +35,112 @@ @L["Anomaly.Kind.SuspensionFlip"] -
- @foreach (var item in _placeholderFeed) - { -
-
- @item.Time -
-
-
@item.Match
-
@item.Detail
-
- - - @($"{item.Score:0.00}") - -
- } -
+ @if (!_summary.HasAnyData) + { + @* First-run: nothing captured yet. Make the next step unmissable. *@ +
+ + @L["Home.Empty.Heading"] + +

+ @L["Home.Empty"] +

+ +
+ } + else if (_summary.LatestSignals.Count == 0) + { + @* Capturing, but the detector hasn't flagged anything yet. *@ +
+

@L["Home.NoSignals"]

+ + @L["Home.ViewAll"] → + +
+ } + else + { + -
- @L["Home.Empty"] -
+ + }
@code { - // Mock data — Phase 6+ will replace with live queries. - private readonly int _eventsTracked = 0; - private readonly int _snapshotsToday = 0; - private readonly int _anomalies = 0; + private DashboardSummary _summary = DashboardSummary.Empty; - private record FeedItem(string Time, string Match, string Detail, decimal Score); + private string? AnomaliesDelta => _summary.AnomaliesToday > 0 + ? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday) + : null; - private readonly List _placeholderFeed = new(); + protected override async Task OnInitializedAsync() + { + try + { + _summary = await Dashboard.GetAsync(CancellationToken.None); + } + catch (Exception ex) + { + // Dashboard is read-only chrome; on failure keep the empty summary and log. + Logger.LogError(ex, "Home: failed to load dashboard summary."); + } + } + + private string FormatSignalTime(DateTimeOffset at) + { + var moscow = at.ToOffset(MoscowTime.Offset); + return moscow.Date == MoscowTime.Now.Date + ? moscow.ToString("HH:mm", CultureInfo.InvariantCulture) + : moscow.ToString("dd MMM", CultureInfo.InvariantCulture); + } + + private string SportLabel(int code) => SportLabels.Resolve(L, code); + + private string SeverityLabel(AnomalySeverity severity) => severity switch + { + AnomalySeverity.High => L["Anomaly.Severity.High"], + AnomalySeverity.Medium => L["Anomaly.Severity.Medium"], + _ => L["Anomaly.Severity.Low"], + }; } diff --git a/src/Marathon.UI/Pages/MyBets/Journal.razor b/src/Marathon.UI/Pages/MyBets/Journal.razor index 1cb1c08..fee5a80 100644 --- a/src/Marathon.UI/Pages/MyBets/Journal.razor +++ b/src/Marathon.UI/Pages/MyBets/Journal.razor @@ -729,6 +729,7 @@ await Service.AddAsync(_form, ct); _form = new AddBetForm(); _formError = null; + Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success); await LoadAsync(); } catch (ArgumentException ex) diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 721dc92..16edb89 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -79,6 +79,14 @@ Flip detector XLSX export No data yet. Enable the background pollers in Settings to start the feed. + Nothing captured yet + Open Settings + Capturing lines — no flips flagged yet. + View all signals + {0} new today + Capturing + Paused + Data capture status Configuration Settings @@ -407,6 +415,7 @@ No bets recorded yet. Use the form above to log a wager — once the event finishes the journal will auto-grade it and compute closing-line value against the latest pre-match snapshot. Failed to save bet — check the event ID and try again. + Bet recorded. No pending bets needed grading. Graded {0} pending bet(s). Delete this bet permanently? @@ -432,6 +441,7 @@ Kelly Run simulation Simulating… + Cancel Final bankroll Net profit ROI diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 6c3bccd..19bf4a7 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -82,6 +82,14 @@ Детектор разворота Экспорт XLSX Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных. + Пока ничего не собрано + Открыть настройки + Идёт сбор линий — разворотов пока нет. + Все сигналы + {0} новых сегодня + Идёт сбор + Пауза + Статус сбора данных Конфигурация @@ -420,6 +428,7 @@ Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка. Не удалось сохранить ставку — проверьте ID события и повторите. + Ставка записана. Ожидающих ставок к расчёту нет. Рассчитано ожидающих: {0}. Удалить эту ставку безвозвратно? @@ -445,6 +454,7 @@ Келли Запустить Симуляция… + Отмена Итоговый банк Чистая прибыль ROI diff --git a/src/Marathon.UI/Services/DashboardSummaryService.cs b/src/Marathon.UI/Services/DashboardSummaryService.cs new file mode 100644 index 0000000..f414199 --- /dev/null +++ b/src/Marathon.UI/Services/DashboardSummaryService.cs @@ -0,0 +1,74 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Options; + +namespace Marathon.UI.Services; + +/// +/// Repository-backed implementation of . +/// Composes server-side counts (no full-table materialisation) with the top few +/// anomaly signals and the worker-toggle state. Scoped — captures the per-circuit +/// repository scope like the other browsing services. +/// +public sealed class DashboardSummaryService : IDashboardSummaryService +{ + private const int LatestSignalCount = 5; + + private readonly IEventRepository _events; + private readonly ISnapshotRepository _snapshots; + private readonly IAnomalyRepository _anomalies; + private readonly IAnomalyBrowsingService _anomalyBrowsing; + private readonly IOptionsMonitor _workers; + + public DashboardSummaryService( + IEventRepository events, + ISnapshotRepository snapshots, + IAnomalyRepository anomalies, + IAnomalyBrowsingService anomalyBrowsing, + IOptionsMonitor workers) + { + _events = events ?? throw new ArgumentNullException(nameof(events)); + _snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots)); + _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies)); + _anomalyBrowsing = anomalyBrowsing ?? throw new ArgumentNullException(nameof(anomalyBrowsing)); + _workers = workers ?? throw new ArgumentNullException(nameof(workers)); + } + + public async Task GetAsync(CancellationToken ct) + { + var todayStart = new DateTimeOffset(MoscowTime.Now.Date, MoscowTime.Offset); + + var eventsTracked = await _events.CountAsync(ct).ConfigureAwait(false); + var snapshotsToday = await _snapshots.CountSinceAsync(todayStart, ct).ConfigureAwait(false); + // DateTimeOffset.MinValue lower bound counts every row (year dominates the + // lexical comparison regardless of offset). + var anomaliesTotal = await _anomalies.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false); + var anomaliesToday = await _anomalies.CountSinceAsync(todayStart, ct).ConfigureAwait(false); + var sports = await _events.ListDistinctSportCodesAsync(ct).ConfigureAwait(false); + + var latest = anomaliesTotal == 0 + ? (IReadOnlyList)Array.Empty() + : (await _anomalyBrowsing.ListAsync(new AnomalyFilter(), ct).ConfigureAwait(false)) + .Take(LatestSignalCount) + .ToList(); + + var w = _workers.CurrentValue; + + return new DashboardSummary( + EventsTracked: eventsTracked, + SnapshotsToday: snapshotsToday, + AnomaliesTotal: anomaliesTotal, + AnomaliesToday: anomaliesToday, + SportsCovered: sports.Count, + LatestSignals: latest, + ScheduleStatus: StageStatus(w.UpcomingPollerEnabled, eventsTracked > 0), + SnapshotStatus: StageStatus(w.LivePollerEnabled, snapshotsToday > 0), + DetectorStatus: StageStatus(w.AnomalyDetectionEnabled, anomaliesTotal > 0), + ExportStatus: eventsTracked > 0 ? "ok" : "idle"); + } + + // Maps a worker stage to the PipelineStep token: disabled → idle, enabled but + // not yet producing → warn (waiting), enabled and producing → ok. + private static string StageStatus(bool enabled, bool producing) => + !enabled ? "idle" : producing ? "ok" : "warn"; +} diff --git a/src/Marathon.UI/Services/IDashboardSummaryService.cs b/src/Marathon.UI/Services/IDashboardSummaryService.cs new file mode 100644 index 0000000..5d07ceb --- /dev/null +++ b/src/Marathon.UI/Services/IDashboardSummaryService.cs @@ -0,0 +1,45 @@ +namespace Marathon.UI.Services; + +/// +/// Live figures for the dashboard (Home) page: real counts, the most recent +/// anomaly signals, and a per-stage capture-pipeline health read derived from the +/// worker toggles + whether each stage is actually producing data. +/// +public interface IDashboardSummaryService +{ + Task GetAsync(CancellationToken ct); +} + +/// +/// Snapshot of dashboard figures. drives the first-run +/// empty state. Pipeline statuses use the PipelineStep token vocabulary +/// (ok / warn / idle / error). +/// +public sealed record DashboardSummary( + int EventsTracked, + int SnapshotsToday, + int AnomaliesTotal, + int AnomaliesToday, + int SportsCovered, + IReadOnlyList LatestSignals, + string ScheduleStatus, + string SnapshotStatus, + string DetectorStatus, + string ExportStatus) +{ + /// True once anything has been captured — gates the welcome/empty state. + public bool HasAnyData => EventsTracked > 0 || AnomaliesTotal > 0; + + /// An empty summary used as the initial render state before data loads. + public static DashboardSummary Empty { get; } = new( + EventsTracked: 0, + SnapshotsToday: 0, + AnomaliesTotal: 0, + AnomaliesToday: 0, + SportsCovered: 0, + LatestSignals: Array.Empty(), + ScheduleStatus: "idle", + SnapshotStatus: "idle", + DetectorStatus: "idle", + ExportStatus: "idle"); +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index ae990d0..9502005 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -61,6 +61,7 @@ public static class UiServicesExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs index 1a3a4a5..89fff81 100644 --- a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs +++ b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using MudBlazor.Services; namespace Marathon.UI.Tests.Support; @@ -37,6 +38,11 @@ public abstract class MarathonTestContext : TestContext Services.AddSingleton(AnomalyBrowsing); Services.AddSingleton(Results); + // WorkerOptions monitor backs the MainLayout capture-status pill (defaults + // to all pollers enabled). Tests needing a specific state can re-register. + Services.AddSingleton>( + new TestOptionsMonitor(new WorkerOptions())); + Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>)); Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); Services.AddLogging();