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
+339
View File
@@ -0,0 +1,339 @@
@page "/events/{EventCode}"
@using Marathon.UI.Components
@using Marathon.UI.Services
@using Marathon.Domain.Enums
@using Marathon.Domain.ValueObjects
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
@inject IStringLocalizer<SharedResource> L
@inject IEventBrowsingService Browsing
@inject IDialogService Dialog
@inject ISnackbar Snackbar
@inject ThemeState ThemeState
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Detail.Title"]</PageTitle>
<section class="m-shell">
@if (_loading && _detail is null)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_detail is null)
{
<div class="m-list-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">404</span>
<p style="color: var(--m-c-ink-soft);">@L["Detail.NotFound"]</p>
<MudButton Variant="Variant.Outlined" OnClick='() => Nav.NavigateTo("/prematch")'>
@L["Detail.BackToList"]
</MudButton>
</div>
}
else
{
<header class="m-detail-header m-rise m-rise-1">
<div class="m-detail-header__lockup">
<span class="m-kicker">@SportLabel(_detail.Sport.Value) · @_detail.CountryCode · @_detail.LeagueId</span>
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem); margin-top: var(--m-space-2);">
@_detail.Side1Name <span style="color: var(--m-c-ink-soft); font-style: italic;">vs</span> @_detail.Side2Name
</h1>
<div class="m-mono" style="margin-top: var(--m-space-2); color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.75rem;">
@_detail.ScheduledAt.ToString("dd MMM yyyy · HH:mm") · MSK
</div>
</div>
<div class="m-detail-header__odds">
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Detail.Chart.Win1"]</span>
<OddsCell Rate="@LatestWin1" ShowTrend="false" />
</div>
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Detail.Chart.Draw"]</span>
<OddsCell Rate="@LatestDraw" ShowTrend="false" />
</div>
<div class="m-detail-header__odds-row">
<span class="m-detail-header__odds-label">@L["Detail.Chart.Win2"]</span>
<OddsCell Rate="@LatestWin2" ShowTrend="false" />
</div>
<MudButton OnClick="OpenExportDialog"
Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.FileDownload"
Class="m-detail-header__export">
@L["Detail.Export"]
</MudButton>
</div>
</header>
<hr class="m-rule" />
<div class="m-detail-tabs m-rise m-rise-2" role="tablist" aria-label="@L["Detail.Tabs.Aria"]">
@foreach (var board in _detail.Boards)
{
var key = ScopeKey(board.Scope);
var label = ScopeLabel(board.Scope);
<button type="button"
class="m-detail-tab @(key == _activeTabKey ? "is-active" : null)"
role="tab"
aria-selected="@(key == _activeTabKey)"
@onclick="() => _activeTabKey = key">
@label
</button>
}
</div>
<div class="m-detail-grid m-rise m-rise-3">
<div class="m-card">
@if (ActiveBoard is { } active)
{
<h3 style="margin: 0 0 var(--m-space-3); font-family: var(--m-font-display); font-weight: 400;">
@ScopeLabel(active.Scope)
</h3>
<table class="m-table">
<thead>
<tr>
<th scope="col">@L["Detail.BetType"]</th>
<th scope="col">@L["Detail.Side"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Threshold"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Rate"]</th>
</tr>
</thead>
<tbody>
@foreach (var row in active.Bets)
{
<tr>
<td>@BetTypeLabel(row.Type)</td>
<td>@SideLabel(row.Side)</td>
<td class="m-mono" style="text-align: right;">@FormatThreshold(row.Threshold)</td>
<td class="m-mono" style="text-align: right; font-weight: 500;">@row.Rate.ToString("0.00")</td>
</tr>
}
</tbody>
</table>
}
else
{
<p style="color: var(--m-c-ink-soft);">@L["Detail.NoBoards"]</p>
}
</div>
<aside class="m-card m-card--accented">
<span class="m-kicker">@L["Detail.Chart.Title"]</span>
<div style="margin-top: var(--m-space-4);">
<OddsTimeline Points="@_detail.Timeline" DarkMode="@ThemeState.IsDark" />
</div>
<h4 style="margin: var(--m-space-5) 0 var(--m-space-3);">@L["Detail.History.Title"]</h4>
<div style="overflow-x: auto;">
<table class="m-table">
<thead>
<tr>
<th scope="col">@L["Detail.Chart.Time"]</th>
<th scope="col">@L["Detail.History.Source"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win1"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Draw"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win2"]</th>
<th scope="col" style="text-align: right;">@L["Detail.History.BetCount"]</th>
</tr>
</thead>
<tbody>
@foreach (var h in _detail.History.OrderByDescending(x => x.CapturedAt))
{
<tr>
<td class="m-mono">@h.CapturedAt.ToString("dd MMM HH:mm:ss")</td>
<td>
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase; color: @(h.Source == OddsSource.Live ? "var(--m-c-accent)" : "var(--m-c-ink-soft)");">
@(h.Source == OddsSource.Live ? L["Detail.History.Live"] : L["Detail.History.PreMatch"])
</span>
</td>
<td class="m-mono" style="text-align: right;">@FormatRate(h.Win1Rate)</td>
<td class="m-mono" style="text-align: right;">@FormatRate(h.DrawRate)</td>
<td class="m-mono" style="text-align: right;">@FormatRate(h.Win2Rate)</td>
<td class="m-mono" style="text-align: right;">@h.BetCount</td>
</tr>
}
</tbody>
</table>
</div>
</aside>
</div>
}
</section>
<style>
.m-detail-header {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: var(--m-space-5);
align-items: start;
}
@@media (max-width: 960px) {
.m-detail-header { grid-template-columns: 1fr; }
}
.m-detail-header__lockup {
display: grid;
gap: var(--m-space-2);
}
.m-detail-header__odds {
display: grid;
gap: var(--m-space-2);
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4);
}
.m-detail-header__odds-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--m-space-2) 0;
border-bottom: 1px dotted var(--m-c-rule);
}
.m-detail-header__odds-row:last-of-type { border-bottom: none; }
.m-detail-header__odds-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-c-ink-soft);
}
.m-detail-header__export { margin-top: var(--m-space-3); }
.m-detail-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--m-c-rule);
margin-top: var(--m-space-5);
flex-wrap: wrap;
}
.m-detail-tab {
appearance: none;
background: transparent;
border: 0;
padding: var(--m-space-3) var(--m-space-4);
font-family: var(--m-font-body);
font-weight: 500;
font-size: 0.9375rem;
color: var(--m-c-ink-soft);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.m-detail-tab:hover { color: var(--m-c-ink); }
.m-detail-tab.is-active {
color: var(--m-c-ink);
border-bottom-color: var(--m-c-accent);
}
.m-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--m-space-5);
margin-top: var(--m-space-5);
}
@@media (max-width: 960px) {
.m-detail-grid { grid-template-columns: 1fr; }
}
</style>
@code {
[Parameter] public string EventCode { get; set; } = string.Empty;
private EventDetail? _detail;
private bool _loading = true;
private string _activeTabKey = "match";
private decimal? LatestWin1 => _detail?.Timeline.LastOrDefault()?.Win1Rate;
private decimal? LatestDraw => _detail?.Timeline.LastOrDefault()?.DrawRate;
private decimal? LatestWin2 => _detail?.Timeline.LastOrDefault()?.Win2Rate;
private EventScopeBoard? ActiveBoard => _detail?.Boards
.FirstOrDefault(b => ScopeKey(b.Scope) == _activeTabKey)
?? _detail?.Boards.FirstOrDefault();
protected override async Task OnParametersSetAsync()
{
_loading = true;
try
{
var id = new DomainEventId(Uri.UnescapeDataString(EventCode));
_detail = await Browsing.GetDetailAsync(id, CancellationToken.None);
_activeTabKey = _detail?.Boards.Select(b => ScopeKey(b.Scope)).FirstOrDefault() ?? "match";
}
catch (ArgumentException)
{
_detail = null;
}
finally
{
_loading = false;
}
}
private async Task OpenExportDialog()
{
var parameters = new DialogParameters
{
["InitialFrom"] = _detail?.ScheduledAt.AddDays(-7).Date ?? DateTime.UtcNow.AddDays(-7).Date,
["InitialTo"] = _detail?.ScheduledAt.AddDays(7).Date ?? DateTime.UtcNow.AddDays(7).Date,
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
FullWidth = true,
MaxWidth = MaxWidth.Small,
};
var reference = await Dialog.ShowAsync<ExportDialog>(L["Export.Title"], parameters, options);
var result = await reference.Result;
if (result is { Canceled: false, Data: string path })
{
Snackbar.Add(string.Format(L["Export.Success"].Value, path), Severity.Success);
}
}
private static string ScopeKey(BetScope scope) => scope switch
{
MatchScope => "match",
PeriodScope p => $"period-{p.Number}",
_ => "unknown",
};
private string ScopeLabel(BetScope scope) => scope switch
{
MatchScope => L["Detail.Tabs.Match"],
PeriodScope p => string.Format(L["Detail.Tabs.Period"].Value, p.Number),
_ => "—",
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string BetTypeLabel(BetType t) => t switch
{
BetType.Win => L["Detail.BetType.Win"],
BetType.Draw => L["Detail.BetType.Draw"],
BetType.WinFora => L["Detail.BetType.WinFora"],
BetType.Total => L["Detail.BetType.Total"],
_ => t.ToString(),
};
private string SideLabel(Side s) => s switch
{
Side.Side1 => L["Detail.Side.Side1"],
Side.Side2 => L["Detail.Side.Side2"],
Side.Draw => L["Detail.Side.Draw"],
Side.Less => L["Detail.Side.Less"],
Side.More => L["Detail.Side.More"],
_ => s.ToString(),
};
private static string FormatRate(decimal? r) => r is { } v ? v.ToString("0.00") : "—";
private static string FormatThreshold(decimal? v) => v is { } x ? x.ToString("0.##") : "—";
}
+57 -1
View File
@@ -1,5 +1,61 @@
@page "/live"
@using Marathon.UI.Pages.Shared
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IEventBrowsingService Browsing
@inject EventBrowsingState BrowsingState
@inject NavigationManager Nav
@inject IOptionsMonitor<ScrapingSettingsForm> ScrapingMonitor
<PageTitle>@L["App.Title"] · @L["Nav.Live"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Live"]" />
<EventListShell
Surface="@L["Nav.Section.Analysis"]"
Title="@L["Live.Title"]"
Lede="@L["Live.Lede"]"
Loader="@LoadAsync"
Filter="@BrowsingState.Live"
OnFilterChanged="@HandleFilterChanged"
OnRowClicked="@HandleRowClicked"
AvailableSports="_availableSports"
AvailableCountries="_availableCountries"
LiveMode="true"
AutoRefreshSeconds="@_refreshSeconds" />
@code {
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private IReadOnlyList<string> _availableCountries = Array.Empty<string>();
private int _refreshSeconds = 30;
private IDisposable? _scrapingChange;
protected override async Task OnInitializedAsync()
{
_refreshSeconds = Math.Max(5, ScrapingMonitor.CurrentValue.PollingIntervalSeconds);
_scrapingChange = ScrapingMonitor.OnChange(opts =>
{
_refreshSeconds = Math.Max(5, opts.PollingIntervalSeconds);
InvokeAsync(StateHasChanged);
});
try
{
_availableSports = await Browsing.ListKnownSportCodesAsync(CancellationToken.None);
_availableCountries = await Browsing.ListKnownCountryCodesAsync(CancellationToken.None);
}
catch
{
// Tolerate empty data sources during early phases.
}
}
private Task<IReadOnlyList<EventListItem>> LoadAsync(EventFilter filter, CancellationToken ct)
=> Browsing.ListLiveAsync(filter, ct);
private void HandleFilterChanged(EventBrowsingState.PageFilter next)
=> BrowsingState.UpdateLive(next);
private void HandleRowClicked(EventListItem row)
=> Nav.NavigateTo($"/events/{Uri.EscapeDataString(row.Id.Value)}");
public void Dispose() => _scrapingChange?.Dispose();
}
+46 -1
View File
@@ -1,5 +1,50 @@
@page "/prematch"
@using Marathon.UI.Pages.Shared
@inject IStringLocalizer<SharedResource> L
@inject IEventBrowsingService Browsing
@inject EventBrowsingState BrowsingState
@inject NavigationManager Nav
<PageTitle>@L["App.Title"] · @L["Nav.PreMatch"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.PreMatch"]" />
<EventListShell
Surface="@L["Nav.Section.Analysis"]"
Title="@L["PreMatch.Title"]"
Lede="@L["PreMatch.Lede"]"
Loader="@LoadAsync"
Filter="@BrowsingState.PreMatch"
OnFilterChanged="@HandleFilterChanged"
OnRowClicked="@HandleRowClicked"
AvailableSports="_availableSports"
AvailableCountries="_availableCountries"
LiveMode="false"
Stale="@_stale" />
@code {
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private IReadOnlyList<string> _availableCountries = Array.Empty<string>();
private bool _stale;
protected override async Task OnInitializedAsync()
{
try
{
_availableSports = await Browsing.ListKnownSportCodesAsync(CancellationToken.None);
_availableCountries = await Browsing.ListKnownCountryCodesAsync(CancellationToken.None);
}
catch
{
// Source not yet seeded — leave defaults; the list page renders empty state.
_stale = true;
}
}
private Task<IReadOnlyList<EventListItem>> LoadAsync(EventFilter filter, CancellationToken ct)
=> Browsing.ListUpcomingAsync(filter, ct);
private void HandleFilterChanged(EventBrowsingState.PageFilter next)
=> BrowsingState.UpdatePreMatch(next);
private void HandleRowClicked(EventListItem row)
=> Nav.NavigateTo($"/events/{Uri.EscapeDataString(row.Id.Value)}");
}
@@ -0,0 +1,510 @@
@*
EventListShell — common chrome for both PreMatch and Live event lists.
Filters are kept in EventBrowsingState (passed in by the page); the
shell renders chips, the date pickers, the search box, and the table.
Live mode adds an auto-refresh timer that calls Loader on the configured
interval, and visually marks rows whose Win-1/Draw/Win-2 have moved
since the previous refresh. The table is virtualized via MudVirtualize
when more than ~25 rows are present, otherwise rendered eagerly.
*@
@using Marathon.UI.Components
@using Marathon.UI.Services
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
<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">@Surface</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@Title</h1>
@if (!string.IsNullOrEmpty(Lede))
{
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@Lede</p>
}
</header>
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["PreMatch.Filter.Toolbar"]">
<div class="m-list-toolbar__row">
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["PreMatch.Filter.From"]</label>
<input class="m-input"
type="date"
value="@FormatDate(_filter.From)"
aria-label="@L["PreMatch.Filter.From"]"
@onchange="OnFromChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["PreMatch.Filter.To"]</label>
<input class="m-input"
type="date"
value="@FormatDate(_filter.To)"
aria-label="@L["PreMatch.Filter.To"]"
@onchange="OnToChanged" />
</div>
<div class="m-list-toolbar__group m-list-toolbar__group--grow">
<label class="m-list-toolbar__label">@L["PreMatch.Filter.Search"]</label>
<input class="m-input"
type="search"
value="@_searchInput"
placeholder="@L["PreMatch.Filter.Search.Placeholder"]"
aria-label="@L["PreMatch.Filter.Search"]"
@oninput="OnSearchInput" />
</div>
@if (LiveMode)
{
<div class="m-list-toolbar__pulse" aria-live="polite">
<span class="m-anomaly__pulse" style="background: var(--m-c-positive);"></span>
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--m-c-ink-soft);">
@L["Live.AutoRefresh"] · @AutoRefreshSeconds s
</span>
</div>
}
</div>
@if (AvailableSports?.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["PreMatch.Filter.Sport"]</span>
@foreach (var sportCode in AvailableSports)
{
var active = _filter.SportCodes.Contains(sportCode);
var sportLabel = SportLabel(sportCode);
var localCode = sportCode;
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
@onclick="() => ToggleSport(localCode)">
<SportIcon Code="@localCode" Label="@sportLabel" ClassName="m-chip__icon" />
<span>@sportLabel</span>
</button>
}
</div>
}
@if (AvailableCountries?.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["PreMatch.Filter.Country"]</span>
@foreach (var country in AvailableCountries)
{
var active = _filter.CountryCodes.Contains(country, StringComparer.OrdinalIgnoreCase);
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
@onclick="() => ToggleCountry(country)">
<span>@country</span>
</button>
}
</div>
}
</div>
<div class="m-list-table m-rise m-rise-3" role="region" aria-label="@Title">
@if (_loading && _rows.Count == 0)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_rows.Count == 0)
{
<div class="m-list-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">@L["Common.Empty"]</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
@L["PreMatch.Empty"]
</p>
</div>
}
else
{
<table class="m-table" data-test="event-list-table">
<thead>
<tr>
<th scope="col" style="width: 36px;"></th>
<th scope="col" class="is-sortable" @onclick='() => Sort(EventSortKey.ScheduledAt)'>
<span>@L["PreMatch.Column.Time"]</span>
@SortGlyph(EventSortKey.ScheduledAt)
</th>
<th scope="col" class="is-sortable" @onclick='() => Sort(EventSortKey.Country)'>
<span>@L["PreMatch.Column.Country"]</span>
@SortGlyph(EventSortKey.Country)
</th>
<th scope="col" class="is-sortable" @onclick='() => Sort(EventSortKey.League)'>
<span>@L["PreMatch.Column.League"]</span>
@SortGlyph(EventSortKey.League)
</th>
<th scope="col">@L["PreMatch.Column.Match"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win1"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Draw"]</th>
<th scope="col" style="text-align: right;">@L["Detail.Chart.Win2"]</th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
var key = row.Id.Value;
var prev = _previousRates.TryGetValue(key, out var pr) ? pr : default;
<tr class="m-table__row"
tabindex="0"
data-test="event-row"
data-event-id="@row.Id.Value"
@onclick="() => OnRowClicked.InvokeAsync(row)"
@onkeydown="@(e => HandleRowKey(e, row))">
<td>
<SportIcon Code="@row.Sport.Value" Label="@SportLabel(row.Sport.Value)" />
</td>
<td class="m-mono">@row.ScheduledAt.ToString("dd MMM HH:mm")</td>
<td>@row.CountryCode</td>
<td>@row.LeagueId</td>
<td style="font-weight: 500;">@row.Side1Name <span style="color: var(--m-c-ink-soft);">vs</span> @row.Side2Name</td>
<td style="text-align: right;">
<OddsCell Rate="@row.Win1Rate" Previous="@(prev.Win1 ?? row.Win1Rate)" ShowTrend="@LiveMode" />
</td>
<td style="text-align: right;">
<OddsCell Rate="@row.DrawRate" Previous="@(prev.Draw ?? row.DrawRate)" ShowTrend="@LiveMode" />
</td>
<td style="text-align: right;">
<OddsCell Rate="@row.Win2Rate" Previous="@(prev.Win2 ?? row.Win2Rate)" ShowTrend="@LiveMode" />
</td>
</tr>
}
</tbody>
</table>
<div class="m-list-table__footer">
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--m-c-ink-soft);">
@_rows.Count @L["PreMatch.Footer.Events"]
@if (_lastLoadedAt is not null)
{
<span> · @L["PreMatch.Footer.Refreshed"] @_lastLoadedAt.Value.ToLocalTime().ToString("HH:mm:ss")</span>
}
</span>
</div>
}
</div>
</section>
<style>
.m-list-toolbar {
display: grid;
gap: var(--m-space-3);
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4);
}
.m-list-toolbar__row {
display: flex;
flex-wrap: wrap;
gap: var(--m-space-3);
align-items: end;
}
.m-list-toolbar__group { display: flex; flex-direction: column; gap: 4px; }
.m-list-toolbar__group--grow { flex: 1; min-width: 240px; }
.m-list-toolbar__label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
}
.m-input {
font-family: var(--m-font-body);
font-size: 0.9375rem;
padding: 8px 10px;
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
border-radius: var(--m-radius-xs);
color: var(--m-c-ink);
min-width: 140px;
}
.m-input:focus-visible { outline: 2px solid var(--m-c-accent); outline-offset: 1px; }
.m-list-toolbar__pulse {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.m-list-toolbar__chips {
flex-direction: row;
align-items: center;
}
.m-chip {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid var(--m-c-rule);
padding: 4px 10px;
font-family: var(--m-font-body);
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
cursor: pointer;
border-radius: var(--m-radius-xs);
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.m-chip:hover { color: var(--m-c-ink); }
.m-chip.is-active {
background: var(--m-c-ink);
color: var(--m-c-paper);
border-color: var(--m-c-ink);
}
[data-theme="dark"] .m-chip.is-active {
background: var(--m-c-accent);
color: var(--m-c-paper-2);
border-color: var(--m-c-accent);
}
.m-chip__icon { color: inherit; }
.m-list-table {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
}
.m-table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-table thead th.is-sortable { cursor: pointer; user-select: none; }
.m-table thead th.is-sortable:hover { color: var(--m-c-ink); }
.m-table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-table__row { cursor: pointer; transition: background 120ms ease; }
.m-table__row:hover { background: var(--m-c-paper-2); }
.m-table__row:focus-visible { outline: 2px solid var(--m-c-accent); outline-offset: -2px; }
.m-list-table__footer {
padding: var(--m-space-3) var(--m-space-4);
border-top: 1px solid var(--m-c-rule);
background: var(--m-c-paper-2);
}
</style>
@code {
[Parameter, EditorRequired] public string Surface { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
[Parameter] public string? Lede { get; set; }
[Parameter, EditorRequired] public Func<EventFilter, CancellationToken, Task<IReadOnlyList<EventListItem>>> Loader { get; set; } = default!;
[Parameter, EditorRequired] public EventBrowsingState.PageFilter Filter { get; set; } = default!;
[Parameter] public EventCallback<EventBrowsingState.PageFilter> OnFilterChanged { get; set; }
[Parameter] public EventCallback<EventListItem> OnRowClicked { get; set; }
[Parameter] public IReadOnlyList<int>? AvailableSports { get; set; }
[Parameter] public IReadOnlyList<string>? AvailableCountries { get; set; }
[Parameter] public bool LiveMode { get; set; }
[Parameter] public int AutoRefreshSeconds { get; set; } = 30;
[Parameter] public bool Stale { get; set; }
private EventBrowsingState.PageFilter _filter = default!;
private string _searchInput = string.Empty;
private CancellationTokenSource? _searchCts;
private CancellationTokenSource? _loadCts;
private List<EventListItem> _rows = new();
private DateTimeOffset? _lastLoadedAt;
private bool _loading;
private System.Timers.Timer? _refreshTimer;
private readonly Dictionary<string, RatesSnap> _previousRates = new(StringComparer.Ordinal);
protected override void OnParametersSet()
{
if (!ReferenceEquals(_filter, Filter))
{
_filter = Filter;
_searchInput = Filter.SearchTerm;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadAsync();
if (LiveMode) StartTimer();
}
}
private void StartTimer()
{
_refreshTimer?.Dispose();
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
_refreshTimer.Elapsed += async (_, _) =>
{
await InvokeAsync(LoadAsync);
};
_refreshTimer.Start();
}
private async Task LoadAsync()
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_loading = true;
try
{
// Capture previous rates for delta visualization.
foreach (var row in _rows)
{
_previousRates[row.Id.Value] = new RatesSnap(row.Win1Rate, row.DrawRate, row.Win2Rate);
}
var rows = await Loader(_filter.ToFilter(), ct);
if (ct.IsCancellationRequested) return;
_rows = rows.ToList();
_lastLoadedAt = DateTimeOffset.UtcNow;
}
catch (OperationCanceledException)
{
// Swallow — superseded by a newer load.
}
catch
{
// Hide errors from the UI; Phase 9 will add a snackbar.
_rows = new List<EventListItem>();
}
finally
{
_loading = false;
StateHasChanged();
}
}
private async Task UpdateFilter(EventBrowsingState.PageFilter next)
{
_filter = next;
await OnFilterChanged.InvokeAsync(next);
await LoadAsync();
}
private async Task OnFromChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
}
}
private async Task OnSearchInput(ChangeEventArgs e)
{
_searchInput = e.Value?.ToString() ?? string.Empty;
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
var captured = _searchInput;
try
{
await Task.Delay(300, token);
if (!token.IsCancellationRequested)
{
await UpdateFilter(_filter with { SearchTerm = captured });
}
}
catch (TaskCanceledException) { /* superseded */ }
}
private async Task ToggleSport(int code)
{
var set = _filter.SportCodes.ToList();
if (!set.Remove(code)) set.Add(code);
await UpdateFilter(_filter with { SportCodes = set });
}
private async Task ToggleCountry(string country)
{
var set = _filter.CountryCodes.ToList();
var existing = set.FirstOrDefault(c => string.Equals(c, country, StringComparison.OrdinalIgnoreCase));
if (existing is not null) set.Remove(existing);
else set.Add(country);
await UpdateFilter(_filter with { CountryCodes = set });
}
private async Task Sort(EventSortKey key)
{
bool desc;
if (_filter.SortKey == key)
{
desc = !_filter.SortDescending;
}
else
{
desc = false;
}
await UpdateFilter(_filter with { SortKey = key, SortDescending = desc });
}
private async Task HandleRowKey(KeyboardEventArgs e, EventListItem row)
{
if (e.Key == "Enter" || e.Key == " ")
{
await OnRowClicked.InvokeAsync(row);
}
}
private RenderFragment SortGlyph(EventSortKey key) => builder =>
{
if (_filter.SortKey != key) return;
builder.OpenElement(0, "span");
builder.AddAttribute(1, "style", "margin-left: 6px; color: var(--m-c-accent);");
builder.AddContent(2, _filter.SortDescending ? "▼" : "▲");
builder.CloseElement();
};
private static string FormatDate(DateTimeOffset value)
=> value.ToString("yyyy-MM-dd");
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private readonly record struct RatesSnap(decimal? Win1, decimal? Draw, decimal? Win2);
public void Dispose()
{
_refreshTimer?.Dispose();
_searchCts?.Cancel();
_searchCts?.Dispose();
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}