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();
}
}
}
+104
View File
@@ -0,0 +1,104 @@
@*
OddsCell — tabular mono rendering of a decimal odds rate, with an inline
direction marker (▲ amber rising, ▼ red falling, em-dash unchanged) when
the value differs from a previous render. Drives the visual indicator
that Phase 6 promises for live-list odds movement.
Rate is the current value. Previous (optional) is the prior value the
caller wants compared against. Caller decides what "previous" means
(last refresh / last snapshot) and tracks it in its own state.
*@
@{
var direction = Direction;
var directionGlyph = direction switch
{
Trend.Up => "▲", // ▲
Trend.Down => "▼", // ▼
_ => "—", // em-dash
};
var directionClass = direction switch
{
Trend.Up => "is-up",
Trend.Down => "is-down",
_ => "is-flat",
};
var ariaTrend = direction switch
{
Trend.Up => "rising",
Trend.Down => "falling",
_ => "unchanged",
};
}
<span class="m-odds @directionClass" aria-label="@AriaPrefix @Formatted (@ariaTrend)" data-trend="@ariaTrend">
<span class="m-odds__value m-mono" data-numeric>@Formatted</span>
@if (ShowTrend)
{
<span class="m-odds__delta" aria-hidden="true">@directionGlyph</span>
}
</span>
<style>
.m-odds {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 56px;
white-space: nowrap;
}
.m-odds__value {
font-feature-settings: "tnum" 1, "lnum" 1;
font-weight: 500;
color: var(--m-c-ink);
font-size: 0.9375rem;
}
.m-odds__delta {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
line-height: 1;
color: var(--m-c-ink-soft);
transition: color 220ms ease;
}
.m-odds.is-up .m-odds__delta { color: var(--m-c-accent); }
.m-odds.is-down .m-odds__delta { color: var(--m-c-anomaly); }
.m-odds.is-flat .m-odds__delta { color: var(--m-c-ink-soft); }
.m-odds.is-up .m-odds__value,
.m-odds.is-down .m-odds__value {
animation: m-odds-flash 1200ms ease-out 1;
}
@@keyframes m-odds-flash {
0% { background: color-mix(in srgb, currentColor 14%, transparent); }
100% { background: transparent; }
}
@@media (prefers-reduced-motion: reduce) {
.m-odds.is-up .m-odds__value,
.m-odds.is-down .m-odds__value { animation: none; }
}
</style>
@code {
[Parameter] public decimal? Rate { get; set; }
[Parameter] public decimal? Previous { get; set; }
[Parameter] public bool ShowTrend { get; set; } = true;
[Parameter] public string AriaPrefix { get; set; } = "Odds";
[Parameter] public string EmptyPlaceholder { get; set; } = "—";
private string Formatted => Rate is { } r ? r.ToString("0.00") : EmptyPlaceholder;
private Trend Direction
{
get
{
if (Rate is not { } r || Previous is not { } p) return Trend.Flat;
if (r > p) return Trend.Up;
if (r < p) return Trend.Down;
return Trend.Flat;
}
}
private enum Trend { Flat, Up, Down }
}
@@ -0,0 +1,242 @@
@*
OddsTimeline — wraps Plotly.Blazor's PlotlyChart with three traces
(Win-1 / Draw / Win-2) and a hidden parallel data table for screen
readers. Matches the editorial-quant theme: parchment paper-fill on
light / ink-near-black on dark, single amber accent for the highlight
rate, mono number formatting on hover labels.
Data is memoized — we only rebuild the trace lists when Points actually
changes (the calling page may re-render frequently during live polling).
*@
@using Plotly.Blazor
@using Plotly.Blazor.LayoutLib
@using Plotly.Blazor.Traces
@using Plotly.Blazor.Traces.ScatterLib
@inject IStringLocalizer<SharedResource> L
<div class="m-timeline">
@if (HasData)
{
<PlotlyChart @ref="_chart" Config="_config" Layout="_layout" Data="_data" />
<details class="m-timeline__a11y">
<summary>@L["Detail.Chart.AccessibleSummary"]</summary>
<table class="m-timeline__table">
<caption class="visually-hidden">@L["Detail.Chart.Title"]</caption>
<thead>
<tr>
<th scope="col">@L["Detail.Chart.Time"]</th>
<th scope="col">@L["Detail.Chart.Win1"]</th>
<th scope="col">@L["Detail.Chart.Draw"]</th>
<th scope="col">@L["Detail.Chart.Win2"]</th>
</tr>
</thead>
<tbody>
@foreach (var p in Points)
{
<tr>
<td class="m-mono">@p.At.ToString("HH:mm:ss") </td>
<td class="m-mono">@FormatRate(p.Win1Rate)</td>
<td class="m-mono">@FormatRate(p.DrawRate)</td>
<td class="m-mono">@FormatRate(p.Win2Rate)</td>
</tr>
}
</tbody>
</table>
</details>
}
else
{
<div class="m-timeline__empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Detail.Chart.Title"]
</span>
<p style="margin-top: var(--m-space-3); color: var(--m-c-ink-soft);">
@L["Detail.Chart.Empty"]
</p>
</div>
}
</div>
<style>
.m-timeline {
display: block;
position: relative;
min-height: 320px;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4);
}
.m-timeline__empty {
display: grid;
place-content: center;
gap: var(--m-space-2);
min-height: 280px;
text-align: center;
}
/* Hidden but accessible — the data-table fallback for screen readers. */
.m-timeline__a11y {
margin-top: var(--m-space-3);
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-timeline__a11y[open] { padding: var(--m-space-3) 0; }
.m-timeline__table {
width: 100%;
border-collapse: collapse;
margin-top: var(--m-space-2);
}
.m-timeline__table th,
.m-timeline__table td {
padding: 6px 10px;
border-bottom: 1px solid var(--m-c-rule);
text-align: left;
}
.m-timeline__table th { color: var(--m-c-ink); }
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
</style>
@code {
[Parameter, EditorRequired] public IReadOnlyList<OddsTimelinePoint> Points { get; set; } = Array.Empty<OddsTimelinePoint>();
[Parameter] public bool DarkMode { get; set; }
private PlotlyChart? _chart;
private Config _config = new() { Responsive = true, DisplayLogo = false };
private Layout _layout = new();
private IList<ITrace> _data = new List<ITrace>();
private int _signature = -1;
private bool HasData => Points.Count > 0;
protected override void OnParametersSet()
{
// Memoize: only rebuild traces/layout when the input identity changes.
var sig = ComputeSignature(Points);
if (sig == _signature) return;
_signature = sig;
_layout = BuildLayout(DarkMode);
_data = BuildTraces(Points);
}
private static int ComputeSignature(IReadOnlyList<OddsTimelinePoint> points)
{
// Cheap stable signature: count + first/last timestamps + first/last
// rates. Sufficient to invalidate the memo on any meaningful change.
if (points.Count == 0) return 0;
var first = points[0];
var last = points[^1];
return HashCode.Combine(
points.Count,
first.At.Ticks,
last.At.Ticks,
first.Win1Rate,
last.Win1Rate,
first.DrawRate,
last.DrawRate,
first.Win2Rate);
}
private static IList<ITrace> BuildTraces(IReadOnlyList<OddsTimelinePoint> points)
{
var times = points.Select(p => (object)p.At.UtcDateTime).ToList();
return new List<ITrace>
{
BuildSeries("Win 1", "#0f172a", points.Select(p => (object?)p.Win1Rate).ToList(), times),
BuildSeries("Draw", "#d97706", points.Select(p => (object?)p.DrawRate).ToList(), times),
BuildSeries("Win 2", "#dc2626", points.Select(p => (object?)p.Win2Rate).ToList(), times),
};
}
private static Scatter BuildSeries(string name, string color, IList<object?> y, IList<object> x)
{
// Plotly's IList<object> doesn't accept nulls well — cast nulls to DBNull.
var ys = y.Select(v => v ?? (object)DBNull.Value).ToList();
return new Scatter
{
Name = name,
Mode = ModeFlag.Lines | ModeFlag.Markers,
X = x,
Y = ys,
Line = new Plotly.Blazor.Traces.ScatterLib.Line { Color = color, Width = 1.6m, Shape = Plotly.Blazor.Traces.ScatterLib.LineLib.ShapeEnum.Linear },
Marker = new Plotly.Blazor.Traces.ScatterLib.Marker { Size = 5, Color = color },
ConnectGaps = false,
};
}
private static Layout BuildLayout(bool dark) => new()
{
AutoSize = true,
Margin = new Plotly.Blazor.LayoutLib.Margin { L = 56, R = 24, T = 24, B = 48 },
PaperBgColor = dark ? "#1c1917" : "#fafaf7",
PlotBgColor = dark ? "#0c0a09" : "#fafaf7",
Font = new Plotly.Blazor.LayoutLib.Font
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 11,
Color = dark ? "#f5f5f4" : "#0f172a",
},
XAxis = new List<XAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = string.Empty },
ShowGrid = true,
GridColor = dark ? "#292524" : "#e7e5e4",
ZeroLine = false,
LineColor = dark ? "#292524" : "#e7e5e4",
TickFont = new Plotly.Blazor.LayoutLib.XAxisLib.TickFont
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 10,
Color = dark ? "#a8a29e" : "#475569",
},
},
},
YAxis = new List<YAxis>
{
new()
{
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = string.Empty },
ShowGrid = true,
GridColor = dark ? "#292524" : "#e7e5e4",
ZeroLine = false,
LineColor = dark ? "#292524" : "#e7e5e4",
TickFont = new Plotly.Blazor.LayoutLib.YAxisLib.TickFont
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 10,
Color = dark ? "#a8a29e" : "#475569",
},
},
},
ShowLegend = true,
Legend = new List<Plotly.Blazor.LayoutLib.Legend>
{
new()
{
Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H,
X = 0m,
Y = 1.12m,
Font = new Plotly.Blazor.LayoutLib.LegendLib.Font
{
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
Size = 11,
Color = dark ? "#e7e5e4" : "#1e293b",
},
},
},
};
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
}
@@ -0,0 +1,78 @@
@*
SportIcon — minimal, distinctive inline-SVG icons per sport. Inline (not
icon library) so the wordmark stays editorial-quant: thin strokes, sharp
corners, no shadowing. Coverage matches Phase 0 spike findings:
Basketball=6, Football=11, Tennis=22723, Hockey=43658.
*@
<span class="m-sport @ClassName" role="img" aria-label="@Label" title="@Label" data-sport="@Code">
@SvgContent
</span>
<style>
.m-sport {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--m-sport-size, 18px);
height: var(--m-sport-size, 18px);
color: var(--m-sport-color, var(--m-c-ink-soft));
flex: 0 0 auto;
}
.m-sport svg { width: 100%; height: 100%; display: block; }
.m-sport[data-sport="6"] { color: #d97706; }
.m-sport[data-sport="11"] { color: #15803d; }
.m-sport[data-sport="22723"] { color: #0369a1; }
.m-sport[data-sport="43658"] { color: #6d28d9; }
[data-theme="dark"] .m-sport { filter: brightness(1.1); }
</style>
@code {
[Parameter, EditorRequired] public int Code { get; set; }
[Parameter] public string? Label { get; set; }
[Parameter] public string? ClassName { get; set; }
private MarkupString SvgContent => new(GetSvg(Code));
private static string GetSvg(int code) => code switch
{
6 => Basketball,
11 => Football,
22723 => Tennis,
43658 => Hockey,
_ => Generic,
};
// SVG glyphs — single-quote attributes so the entire literal can stay on one line.
private const string Basketball =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
"<circle cx='12' cy='12' r='9' />" +
"<path d='M3 12h18 M12 3v18 M5 5c4 4 10 4 14 0 M5 19c4-4 10-4 14 0' />" +
"</svg>";
private const string Football =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linejoin='round'>" +
"<circle cx='12' cy='12' r='9' />" +
"<polygon points='12,7 16.5,10 14.5,15 9.5,15 7.5,10' />" +
"</svg>";
private const string Tennis =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
"<circle cx='12' cy='12' r='9' />" +
"<path d='M3.5 9 C 9 9 15 15 20.5 15' />" +
"<path d='M3.5 15 C 9 15 15 9 20.5 9' />" +
"</svg>";
private const string Hockey =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round'>" +
"<ellipse cx='12' cy='14' rx='8' ry='3' />" +
"<path d='M4 14 L4 11 M20 14 L20 11' />" +
"<path d='M6 7 L18 4' />" +
"</svg>";
private const string Generic =
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'>" +
"<circle cx='12' cy='12' r='9' />" +
"<circle cx='12' cy='12' r='3' />" +
"</svg>";
}