feat(phase-6): event browsing UI — pre-match/live lists, detail page, +26 bUnit tests

Replaces PreMatch/Live placeholder pages with a shared EventListShell
(filter chips, date range, sortable virtualized-friendly table, debounced
search, live auto-refresh with odds-movement indicators) and adds a new
/events/{eventCode} detail page (asymmetric header lockup, dynamic
Match/Period tabs, Plotly.Blazor odds-over-time chart with accessible
data-table fallback, snapshot history, Excel export modal).

New primitives matching Phase 5's editorial-quant system:
- SportIcon: inline SVGs per sport (basketball=6, football=11,
  tennis=22723, hockey=43658, generic fallback)
- OddsCell: tabular mono with ▲/▼/— delta + flash on change
  (prefers-reduced-motion honored)
- OddsTimeline: Plotly.Blazor wrapper with theme-aware colors and
  <details>/<summary> data-table screen-reader fallback
- ExportDialog: From/To pickers + ExportKind radio + Esc/Enter
  keyboard, surfaces use-case errors inline
- EventListShell: shared section shell for PreMatch/Live cadence

State + service split keeps the RCL host-agnostic:
- IEventBrowsingService / EventBrowsingService — wraps repos, returns
  view-model records (EventListItem, EventDetail, EventScopeBoard,
  BetRow, OddsTimelinePoint, SnapshotHistoryEntry); pages never see
  EF or domain entities directly.
- EventBrowsingState — singleton (per-circuit in BlazorWebView) holding
  immutable PageFilter records for PreMatch and Live.

Plotly.Blazor 5.4.1 added (latest .NET 8 line; 7.x has breaking changes).
+59 RU/EN localization keys following the Phase 5 dot-segmented convention.

Tests: +26 bUnit tests (PreMatch/Live/Detail pages, OddsCell/SportIcon/
ExportDialog components, EventBrowsingState). Total 228/228 passing
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline 202).
Build clean (0/0).

PLAN.md: P2/P3/P5 top-level checkboxes ticked; P6 row marked Done.
This commit is contained in:
2026-05-05 12:58:03 +03:00
parent fe97643a41
commit 553db2bce3
32 changed files with 3060 additions and 64 deletions
@@ -0,0 +1,174 @@
@*
ExportDialog — modal that collects a DateRange + ExportKind, then calls
ExportToExcelUseCase. Reports back via DialogResult so the caller can
show a snackbar with the file path. Keyboard: Esc to cancel, Enter to
submit.
*@
@using Marathon.Application.UseCases
@using AppDateRange = Marathon.Application.Storage.DateRange
@using ExportKind = Marathon.Application.Storage.ExportKind
@inject IStringLocalizer<SharedResource> L
@inject ExportToExcelUseCase ExportUseCase
@inject ILogger<ExportDialog> Logger
<MudDialog @key="@(_kind.ToString())" Class="m-export-dialog">
<TitleContent>
<span class="m-kicker">@L["Export.Kicker"]</span>
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.5rem; margin: var(--m-space-2) 0 0;">
@L["Export.Title"]
</h2>
</TitleContent>
<DialogContent>
<div style="display: grid; gap: var(--m-space-4); padding: var(--m-space-2) 0;" @onkeydown="HandleKeyDown">
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.DateRange.From"]</label>
</div>
<div>
<MudDatePicker
@bind-Date="_from"
Label="@L["Export.DateRange.From"]"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
</div>
</div>
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.DateRange.To"]</label>
</div>
<div>
<MudDatePicker
@bind-Date="_to"
Label="@L["Export.DateRange.To"]"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
</div>
</div>
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.Kind.Label"]</label>
</div>
<div>
<MudRadioGroup T="ExportKind" @bind-Value="_kind">
<MudRadio Value="@(ExportKind.PreMatch)" Color="Color.Primary">@L["Export.Kind.PreMatch"]</MudRadio>
<MudRadio Value="@(ExportKind.Live)" Color="Color.Primary">@L["Export.Kind.Live"]</MudRadio>
<MudRadio Value="@(ExportKind.Combined)" Color="Color.Primary">@L["Export.Kind.Combined"]</MudRadio>
</MudRadioGroup>
</div>
</div>
@if (_error is not null)
{
<div role="alert" class="m-export-dialog__error">
@_error
</div>
}
</div>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text">@L["Export.Cancel"]</MudButton>
<MudButton OnClick="Submit"
Color="Color.Primary"
Variant="Variant.Filled"
Disabled="@_busy">
@if (_busy)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Inherit" />
<span style="margin-left: 8px;">@L["Common.Loading"]</span>
}
else
{
@L["Export.Submit"]
}
</MudButton>
</DialogActions>
</MudDialog>
<style>
.m-export-dialog__error {
background: rgba(220, 38, 38, 0.08);
border-left: 3px solid var(--m-c-anomaly);
padding: var(--m-space-3);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
color: var(--m-c-anomaly);
}
</style>
@code {
[CascadingParameter] private MudDialogInstance Dialog { get; set; } = default!;
[Parameter] public DateTime? InitialFrom { get; set; }
[Parameter] public DateTime? InitialTo { get; set; }
[Parameter] public ExportKind InitialKind { get; set; } = ExportKind.Combined;
private DateTime? _from;
private DateTime? _to;
private ExportKind _kind;
private bool _busy;
private string? _error;
protected override void OnInitialized()
{
var moscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3));
_from = InitialFrom ?? moscow.AddDays(-1).Date;
_to = InitialTo ?? moscow.AddDays(1).Date;
_kind = InitialKind;
}
private void Cancel() => Dialog.Cancel();
private async Task Submit()
{
if (_from is null || _to is null)
{
_error = L["Export.Error.MissingDates"];
return;
}
if (_from > _to)
{
_error = L["Export.Error.InvalidRange"];
return;
}
_error = null;
_busy = true;
StateHasChanged();
try
{
// Use Moscow offset to match domain ScheduledAt invariant.
var moscow = TimeSpan.FromHours(3);
var range = new AppDateRange(
new DateTimeOffset(_from.Value.Date, moscow),
new DateTimeOffset(_to.Value.Date.AddDays(1).AddSeconds(-1), moscow));
var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None);
Dialog.Close(DialogResult.Ok(path));
}
catch (Exception ex)
{
Logger.LogError(ex, "Export failed");
_error = L["Export.Error.Failed"].Value + " — " + ex.Message;
}
finally
{
_busy = false;
StateHasChanged();
}
}
private async Task HandleKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
if (e.Key == "Enter" && !_busy)
{
await Submit();
}
else if (e.Key == "Escape")
{
Cancel();
}
}
}