Files
maraphon-app/src/Marathon.UI/Pages/Home.razor
T
alexei.dolgolyov 250a93e718 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.
2026-05-28 22:34:28 +03:00

147 lines
7.4 KiB
Plaintext

@page "/"
@inject IStringLocalizer<SharedResource> L
@inject IDashboardSummaryService Dashboard
@inject ILogger<Home> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</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">@L["Home.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2.5rem, 5vw, 4rem);">@L["Home.Title"]</h1>
<p style="font-size: 1.0625rem; line-height: 1.5; color: var(--m-c-ink-soft); max-width: 60ch;">
@L["Home.Lede"]
</p>
</header>
<hr class="m-rule--double" />
<div class="m-grid--three m-rise m-rise-2">
<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);">
<div class="m-card">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Home.Section.Latest"]
</span>
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.625rem; margin: var(--m-space-3) 0 var(--m-space-5);">
@L["Anomaly.Kind.SuspensionFlip"]
</h2>
@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);">
<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="@_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 {
private DashboardSummary _summary = DashboardSummary.Empty;
private string? AnomaliesDelta => _summary.AnomaliesToday > 0
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday)
: null;
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"],
};
}