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:
@@ -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.##") : "—";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user