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<WorkerOptions>
  in the bUnit test context for layout-rendering tests.
This commit is contained in:
2026-05-28 22:34:28 +03:00
parent 0501f9c39c
commit 250a93e718
10 changed files with 278 additions and 35 deletions
+17
View File
@@ -3,6 +3,7 @@
@inject ThemeState ThemeState
@inject LocaleState LocaleState
@inject IStringLocalizer<SharedResource> L
@inject IOptionsMonitor<WorkerOptions> Workers
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
<MudPopoverProvider />
@@ -24,6 +25,12 @@
<div class="m-appbar__spacer"></div>
<div class="m-appbar__tools m-rise m-rise-2">
<span class="m-capture-pill" data-test="capture-pill"
aria-label="@L["Scraping.Aria"]" title="@L["Scraping.Aria"]"
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.12em; color:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");">
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");"></span>
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
</span>
<LocaleSwitcher />
<ThemeToggle />
</div>
@@ -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();
}
}
@@ -127,6 +127,15 @@
<span class="m-backtest__submit-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
</button>
@if (_running)
{
<button type="button"
class="m-chip"
@onclick="CancelRun"
data-test="backtest-cancel">
<span>@L["Backtest.Action.Cancel"]</span>
</button>
}
</div>
</article>
</section>
@@ -650,6 +659,8 @@
}
}
private void CancelRun() => _runCts?.Cancel();
private void OnStakeRuleChanged(StakeRule next)
{
_form.StakeRule = next;
+103 -35
View File
@@ -1,5 +1,7 @@
@page "/"
@inject IStringLocalizer<SharedResource> L
@inject IDashboardSummaryService Dashboard
@inject ILogger<Home> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
@@ -15,10 +17,13 @@
<hr class="m-rule--double" />
<div class="m-grid--three m-rise m-rise-2">
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
<StatCard Label="@L["Home.Stat.Events"]" Value="@_summary.EventsTracked.ToString("N0")" />
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_summary.SnapshotsToday.ToString("N0")" />
<StatCard Label="@L["Home.Stat.Anomalies"]"
Value="@_summary.AnomaliesTotal.ToString("N0")"
Delta="@AnomaliesDelta"
Anomaly="true" />
<StatCard Label="@L["Home.Stat.Coverage"]" Value="@_summary.SportsCovered.ToString()" />
</div>
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
@@ -30,49 +35,112 @@
@L["Anomaly.Kind.SuspensionFlip"]
</h2>
<div style="display: grid; gap: var(--m-space-4);">
@foreach (var item in _placeholderFeed)
{
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
@item.Time
</div>
<div>
<div style="font-weight: 500;">@item.Match</div>
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
</div>
<span class="m-anomaly">
<span class="m-anomaly__pulse"></span>
@($"{item.Score:0.00}")
</span>
</article>
}
</div>
@if (!_summary.HasAnyData)
{
@* First-run: nothing captured yet. Make the next step unmissable. *@
<div data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
@L["Home.Empty.Heading"]
</span>
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
@L["Home.Empty"]
</p>
<div>
<a href="/settings" data-test="home-empty-cta"
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border: 1px solid var(--m-c-accent); color: var(--m-c-accent); font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
@L["Home.Empty.Cta"] →
</a>
</div>
</div>
}
else if (_summary.LatestSignals.Count == 0)
{
@* Capturing, but the detector hasn't flagged anything yet. *@
<div data-test="home-no-signals" style="display: grid; gap: var(--m-space-3); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
<p style="color: var(--m-c-ink-soft); margin: 0;">@L["Home.NoSignals"]</p>
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
@L["Home.ViewAll"] →
</a>
</div>
}
else
{
<div style="display: grid; gap: var(--m-space-4);">
@foreach (var signal in _summary.LatestSignals)
{
<a href="@($"/anomalies/{signal.Id}")" data-test="home-signal"
style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule); text-decoration: none; color: inherit;">
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
@FormatSignalTime(signal.DetectedAt)
</div>
<div>
<div style="font-weight: 500;">@signal.EventTitle</div>
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">
@SportLabel(signal.Sport.Value) · @SeverityLabel(signal.Severity)
</div>
</div>
<span class="m-anomaly">
<span class="m-anomaly__pulse"></span>
@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)
</span>
</a>
}
</div>
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
@L["Home.Empty"]
</div>
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
@L["Home.ViewAll"] →
</a>
</div>
}
</div>
<aside class="m-card m-card--accented">
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="@_summary.ScheduleStatus" />
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="@_summary.SnapshotStatus" />
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="@_summary.DetectorStatus" />
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="@_summary.ExportStatus" />
</ol>
</aside>
</div>
</section>
@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<FeedItem> _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"],
};
}
@@ -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)
@@ -79,6 +79,14 @@
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
<data name="Home.Empty.Heading"><value>Nothing captured yet</value></data>
<data name="Home.Empty.Cta"><value>Open Settings</value></data>
<data name="Home.NoSignals"><value>Capturing lines — no flips flagged yet.</value></data>
<data name="Home.ViewAll"><value>View all signals</value></data>
<data name="Home.Stat.NewToday"><value>{0} new today</value></data>
<data name="Scraping.On"><value>Capturing</value></data>
<data name="Scraping.Off"><value>Paused</value></data>
<data name="Scraping.Aria"><value>Data capture status</value></data>
<data name="Settings.Kicker"><value>Configuration</value></data>
<data name="Settings.Title"><value>Settings</value></data>
@@ -407,6 +415,7 @@
<data name="Journal.Empty.None"><value>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.</value></data>
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
<data name="Journal.Error.Generic"><value>Failed to save bet — check the event ID and try again.</value></data>
<data name="Journal.Submitted"><value>Bet recorded.</value></data>
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
@@ -432,6 +441,7 @@
<data name="Backtest.StakeRule.Kelly"><value>Kelly</value></data>
<data name="Backtest.Action.Run"><value>Run simulation</value></data>
<data name="Backtest.Action.Running"><value>Simulating…</value></data>
<data name="Backtest.Action.Cancel"><value>Cancel</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Final bankroll</value></data>
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
@@ -82,6 +82,14 @@
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
<data name="Home.Empty.Heading"><value>Пока ничего не собрано</value></data>
<data name="Home.Empty.Cta"><value>Открыть настройки</value></data>
<data name="Home.NoSignals"><value>Идёт сбор линий — разворотов пока нет.</value></data>
<data name="Home.ViewAll"><value>Все сигналы</value></data>
<data name="Home.Stat.NewToday"><value>{0} новых сегодня</value></data>
<data name="Scraping.On"><value>Идёт сбор</value></data>
<data name="Scraping.Off"><value>Пауза</value></data>
<data name="Scraping.Aria"><value>Статус сбора данных</value></data>
<!-- Settings — sections -->
<data name="Settings.Kicker"><value>Конфигурация</value></data>
@@ -420,6 +428,7 @@
<data name="Journal.Empty.None"><value>Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка.</value></data>
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
<data name="Journal.Error.Generic"><value>Не удалось сохранить ставку — проверьте ID события и повторите.</value></data>
<data name="Journal.Submitted"><value>Ставка записана.</value></data>
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
@@ -445,6 +454,7 @@
<data name="Backtest.StakeRule.Kelly"><value>Келли</value></data>
<data name="Backtest.Action.Run"><value>Запустить</value></data>
<data name="Backtest.Action.Running"><value>Симуляция…</value></data>
<data name="Backtest.Action.Cancel"><value>Отмена</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Итоговый банк</value></data>
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
@@ -0,0 +1,74 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Options;
namespace Marathon.UI.Services;
/// <summary>
/// Repository-backed implementation of <see cref="IDashboardSummaryService"/>.
/// 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.
/// </summary>
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<WorkerOptions> _workers;
public DashboardSummaryService(
IEventRepository events,
ISnapshotRepository snapshots,
IAnomalyRepository anomalies,
IAnomalyBrowsingService anomalyBrowsing,
IOptionsMonitor<WorkerOptions> 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<DashboardSummary> 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<AnomalyListItem>)Array.Empty<AnomalyListItem>()
: (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";
}
@@ -0,0 +1,45 @@
namespace Marathon.UI.Services;
/// <summary>
/// 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.
/// </summary>
public interface IDashboardSummaryService
{
Task<DashboardSummary> GetAsync(CancellationToken ct);
}
/// <summary>
/// Snapshot of dashboard figures. <see cref="HasAnyData"/> drives the first-run
/// empty state. Pipeline statuses use the PipelineStep token vocabulary
/// (<c>ok</c> / <c>warn</c> / <c>idle</c> / <c>error</c>).
/// </summary>
public sealed record DashboardSummary(
int EventsTracked,
int SnapshotsToday,
int AnomaliesTotal,
int AnomaliesToday,
int SportsCovered,
IReadOnlyList<AnomalyListItem> LatestSignals,
string ScheduleStatus,
string SnapshotStatus,
string DetectorStatus,
string ExportStatus)
{
/// <summary>True once anything has been captured — gates the welcome/empty state.</summary>
public bool HasAnyData => EventsTracked > 0 || AnomaliesTotal > 0;
/// <summary>An empty summary used as the initial render state before data loads.</summary>
public static DashboardSummary Empty { get; } = new(
EventsTracked: 0,
SnapshotsToday: 0,
AnomaliesTotal: 0,
AnomaliesToday: 0,
SportsCovered: 0,
LatestSignals: Array.Empty<AnomalyListItem>(),
ScheduleStatus: "idle",
SnapshotStatus: "idle",
DetectorStatus: "idle",
ExportStatus: "idle");
}
@@ -61,6 +61,7 @@ public static class UiServicesExtensions
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
services.AddScoped<IBetJournalService, BetJournalService>();
services.AddScoped<IBacktestService, BacktestService>();
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -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<IAnomalyBrowsingService>(AnomalyBrowsing);
Services.AddSingleton<IResultsBrowsingService>(Results);
// WorkerOptions monitor backs the MainLayout capture-status pill (defaults
// to all pollers enabled). Tests needing a specific state can re-register.
Services.AddSingleton<IOptionsMonitor<WorkerOptions>>(
new TestOptionsMonitor<WorkerOptions>(new WorkerOptions()));
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
Services.AddLogging();