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>";
}
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Plotly.Blazor" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
+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();
}
}
@@ -149,4 +149,80 @@
<data name="Anomaly.Live"><value>Anomaly</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 6 — Pre-match list / Live list / Detail / Export -->
<data name="PreMatch.Title"><value>Pre-match schedule</value></data>
<data name="PreMatch.Lede"><value>Upcoming events with their latest pre-match Win-1 / Draw / Win-2 odds preview. Filter by sport, country, league, or team.</value></data>
<data name="PreMatch.Empty"><value>No events match the current filters. Loosen the date range or clear the chips above.</value></data>
<data name="PreMatch.Filter.Toolbar"><value>Filter toolbar</value></data>
<data name="PreMatch.Filter.From"><value>From</value></data>
<data name="PreMatch.Filter.To"><value>To</value></data>
<data name="PreMatch.Filter.Sport"><value>Sports</value></data>
<data name="PreMatch.Filter.Country"><value>Countries</value></data>
<data name="PreMatch.Filter.Search"><value>Search league or team</value></data>
<data name="PreMatch.Filter.Search.Placeholder"><value>e.g. Real Madrid, NBA, Roland Garros…</value></data>
<data name="PreMatch.Column.Time"><value>Time</value></data>
<data name="PreMatch.Column.Country"><value>Country</value></data>
<data name="PreMatch.Column.League"><value>League</value></data>
<data name="PreMatch.Column.Match"><value>Match</value></data>
<data name="PreMatch.Footer.Events"><value>events</value></data>
<data name="PreMatch.Footer.Refreshed"><value>refreshed at</value></data>
<data name="Live.Title"><value>Live odds feed</value></data>
<data name="Live.Lede"><value>Currently-live events with the most recent live snapshot. The list refreshes on the configured polling cadence; rows pulse when their odds move.</value></data>
<data name="Live.AutoRefresh"><value>Auto-refresh</value></data>
<data name="Detail.Title"><value>Event</value></data>
<data name="Detail.NotFound"><value>This event could not be loaded — it may have been removed from the source feed.</value></data>
<data name="Detail.BackToList"><value>Back to schedule</value></data>
<data name="Detail.Export"><value>Export</value></data>
<data name="Detail.Tabs.Aria"><value>Bet scope tabs</value></data>
<data name="Detail.Tabs.Match"><value>Match</value></data>
<data name="Detail.Tabs.Period"><value>Period {0}</value></data>
<data name="Detail.NoBoards"><value>No bets captured yet for this event.</value></data>
<data name="Detail.BetType"><value>Type</value></data>
<data name="Detail.Side"><value>Side</value></data>
<data name="Detail.Threshold"><value>Threshold</value></data>
<data name="Detail.Rate"><value>Rate</value></data>
<data name="Detail.BetType.Win"><value>Win</value></data>
<data name="Detail.BetType.Draw"><value>Draw</value></data>
<data name="Detail.BetType.WinFora"><value>Handicap</value></data>
<data name="Detail.BetType.Total"><value>Total</value></data>
<data name="Detail.Side.Side1"><value>1</value></data>
<data name="Detail.Side.Side2"><value>2</value></data>
<data name="Detail.Side.Draw"><value>X</value></data>
<data name="Detail.Side.Less"><value>Under</value></data>
<data name="Detail.Side.More"><value>Over</value></data>
<data name="Detail.Chart.Title"><value>Odds movement</value></data>
<data name="Detail.Chart.Empty"><value>No snapshots captured yet for this event.</value></data>
<data name="Detail.Chart.Time"><value>Time</value></data>
<data name="Detail.Chart.Win1"><value>Win 1</value></data>
<data name="Detail.Chart.Draw"><value>Draw</value></data>
<data name="Detail.Chart.Win2"><value>Win 2</value></data>
<data name="Detail.Chart.AccessibleSummary"><value>Show data table</value></data>
<data name="Detail.History.Title"><value>Snapshot history</value></data>
<data name="Detail.History.Source"><value>Source</value></data>
<data name="Detail.History.BetCount"><value>Bets</value></data>
<data name="Detail.History.Live"><value>LIVE</value></data>
<data name="Detail.History.PreMatch"><value>PRE</value></data>
<data name="Export.Kicker"><value>Export</value></data>
<data name="Export.Title"><value>Export to Excel</value></data>
<data name="Export.DateRange.From"><value>From date</value></data>
<data name="Export.DateRange.To"><value>To date</value></data>
<data name="Export.Kind.Label"><value>Snapshot kind</value></data>
<data name="Export.Kind.PreMatch"><value>Pre-match only</value></data>
<data name="Export.Kind.Live"><value>Live only</value></data>
<data name="Export.Kind.Combined"><value>Combined</value></data>
<data name="Export.Submit"><value>Export</value></data>
<data name="Export.Cancel"><value>Cancel</value></data>
<data name="Export.Success"><value>Export saved to {0}</value></data>
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
<data name="Export.Error.Failed"><value>Export failed.</value></data>
<data name="Sport.Basketball"><value>Basketball</value></data>
<data name="Sport.Football"><value>Football</value></data>
<data name="Sport.Tennis"><value>Tennis</value></data>
<data name="Sport.Hockey"><value>Hockey</value></data>
</root>
@@ -162,4 +162,80 @@
<data name="Anomaly.Live"><value>Аномалия</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 6 — Список матчей / Лайв / Детали / Экспорт -->
<data name="PreMatch.Title"><value>Расписание до матча</value></data>
<data name="PreMatch.Lede"><value>Предстоящие события с последним предматчевым превью «1 / X / 2». Фильтр по виду спорта, стране, лиге и команде.</value></data>
<data name="PreMatch.Empty"><value>Под текущие фильтры не подпадает ни одно событие. Расширьте диапазон или снимите чипы выше.</value></data>
<data name="PreMatch.Filter.Toolbar"><value>Панель фильтров</value></data>
<data name="PreMatch.Filter.From"><value>С</value></data>
<data name="PreMatch.Filter.To"><value>По</value></data>
<data name="PreMatch.Filter.Sport"><value>Виды спорта</value></data>
<data name="PreMatch.Filter.Country"><value>Страны</value></data>
<data name="PreMatch.Filter.Search"><value>Поиск по лиге или команде</value></data>
<data name="PreMatch.Filter.Search.Placeholder"><value>напр. Реал Мадрид, NBA, Ролан Гаррос…</value></data>
<data name="PreMatch.Column.Time"><value>Время</value></data>
<data name="PreMatch.Column.Country"><value>Страна</value></data>
<data name="PreMatch.Column.League"><value>Лига</value></data>
<data name="PreMatch.Column.Match"><value>Матч</value></data>
<data name="PreMatch.Footer.Events"><value>событий</value></data>
<data name="PreMatch.Footer.Refreshed"><value>обновлено в</value></data>
<data name="Live.Title"><value>Лайв-поток коэффициентов</value></data>
<data name="Live.Lede"><value>Текущие лайв-события с последним сделанным снимком. Список обновляется по настроенному интервалу опроса; строки пульсируют при движении котировки.</value></data>
<data name="Live.AutoRefresh"><value>Автообновление</value></data>
<data name="Detail.Title"><value>Событие</value></data>
<data name="Detail.NotFound"><value>Событие не найдено — возможно, оно убрано из исходного потока.</value></data>
<data name="Detail.BackToList"><value>К расписанию</value></data>
<data name="Detail.Export"><value>Экспорт</value></data>
<data name="Detail.Tabs.Aria"><value>Вкладки разделов ставок</value></data>
<data name="Detail.Tabs.Match"><value>Матч</value></data>
<data name="Detail.Tabs.Period"><value>Период {0}</value></data>
<data name="Detail.NoBoards"><value>Снимков ставок по этому событию ещё нет.</value></data>
<data name="Detail.BetType"><value>Тип</value></data>
<data name="Detail.Side"><value>Сторона</value></data>
<data name="Detail.Threshold"><value>Порог</value></data>
<data name="Detail.Rate"><value>Кэф</value></data>
<data name="Detail.BetType.Win"><value>Победа</value></data>
<data name="Detail.BetType.Draw"><value>Ничья</value></data>
<data name="Detail.BetType.WinFora"><value>Фора</value></data>
<data name="Detail.BetType.Total"><value>Тотал</value></data>
<data name="Detail.Side.Side1"><value>1</value></data>
<data name="Detail.Side.Side2"><value>2</value></data>
<data name="Detail.Side.Draw"><value>X</value></data>
<data name="Detail.Side.Less"><value>Меньше</value></data>
<data name="Detail.Side.More"><value>Больше</value></data>
<data name="Detail.Chart.Title"><value>Динамика коэффициентов</value></data>
<data name="Detail.Chart.Empty"><value>Снимков по этому событию ещё нет.</value></data>
<data name="Detail.Chart.Time"><value>Время</value></data>
<data name="Detail.Chart.Win1"><value>П1</value></data>
<data name="Detail.Chart.Draw"><value>X</value></data>
<data name="Detail.Chart.Win2"><value>П2</value></data>
<data name="Detail.Chart.AccessibleSummary"><value>Показать таблицу значений</value></data>
<data name="Detail.History.Title"><value>История снимков</value></data>
<data name="Detail.History.Source"><value>Источник</value></data>
<data name="Detail.History.BetCount"><value>Ставок</value></data>
<data name="Detail.History.Live"><value>ЛАЙВ</value></data>
<data name="Detail.History.PreMatch"><value>ДО МАТЧА</value></data>
<data name="Export.Kicker"><value>Экспорт</value></data>
<data name="Export.Title"><value>Экспорт в Excel</value></data>
<data name="Export.DateRange.From"><value>Дата начала</value></data>
<data name="Export.DateRange.To"><value>Дата конца</value></data>
<data name="Export.Kind.Label"><value>Тип снимков</value></data>
<data name="Export.Kind.PreMatch"><value>Только до матча</value></data>
<data name="Export.Kind.Live"><value>Только лайв</value></data>
<data name="Export.Kind.Combined"><value>Комбинированный</value></data>
<data name="Export.Submit"><value>Экспорт</value></data>
<data name="Export.Cancel"><value>Отмена</value></data>
<data name="Export.Success"><value>Файл сохранён в {0}</value></data>
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
<data name="Sport.Basketball"><value>Баскетбол</value></data>
<data name="Sport.Football"><value>Футбол</value></data>
<data name="Sport.Tennis"><value>Теннис</value></data>
<data name="Sport.Hockey"><value>Хоккей</value></data>
</root>
@@ -0,0 +1,269 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Repository-backed browsing service. Reads events + their latest snapshot
/// once per call and shapes view-models. Pages call this — never the repos
/// directly — so the UI is shielded from EF tracked graphs and can be re-pointed
/// at a different storage layer later.
/// </summary>
public sealed class EventBrowsingService : IEventBrowsingService
{
private readonly IEventRepository _events;
private readonly ISnapshotRepository _snapshots;
public EventBrowsingService(IEventRepository events, ISnapshotRepository snapshots)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
}
public async Task<IReadOnlyList<EventListItem>> ListUpcomingAsync(EventFilter filter, CancellationToken ct)
=> await BuildListAsync(filter, OddsSource.PreMatch, ct).ConfigureAwait(false);
public async Task<IReadOnlyList<EventListItem>> ListLiveAsync(EventFilter filter, CancellationToken ct)
=> await BuildListAsync(filter, OddsSource.Live, ct).ConfigureAwait(false);
public async Task<EventDetail?> GetDetailAsync(DomainEventId eventId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(eventId);
var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false);
if (ev is null) return null;
var from = ev.ScheduledAt.AddDays(-2);
var to = ev.ScheduledAt.AddDays(2);
var snapshots = await _snapshots
.ListByEventAsync(eventId, from, to, ct)
.ConfigureAwait(false);
var ordered = snapshots
.OrderBy(static s => s.CapturedAt)
.ToList();
var latest = ordered.LastOrDefault();
var boards = latest is null
? Array.Empty<EventScopeBoard>()
: BuildBoards(latest);
var timeline = ordered.Select(BuildTimelinePoint).ToList();
var history = ordered.Select(BuildHistoryEntry).ToList();
return new EventDetail(
ev.Id,
ev.Sport,
ev.CountryCode,
ev.LeagueId,
ev.Side1Name,
ev.Side2Name,
ev.ScheduledAt,
boards,
timeline,
history);
}
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
{
var all = await _events.ListAsync(ct).ConfigureAwait(false);
return all
.Select(static e => e.Sport.Value)
.Distinct()
.OrderBy(static x => x)
.ToList();
}
public async Task<IReadOnlyList<string>> ListKnownCountryCodesAsync(CancellationToken ct)
{
var all = await _events.ListAsync(ct).ConfigureAwait(false);
return all
.Select(static e => e.CountryCode)
.Distinct()
.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
// ---------------- internals ----------------
private async Task<IReadOnlyList<EventListItem>> BuildListAsync(
EventFilter filter,
OddsSource source,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(filter);
var range = new DateRange(filter.Dates.From, filter.Dates.To);
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
// Apply non-temporal filters in-memory — list size is small (UI page).
IEnumerable<Event> filtered = events;
if (filter.SportCodes is { Count: > 0 } sports)
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
if (filter.CountryCodes is { Count: > 0 } countries)
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
{
var term = filter.SearchTerm.Trim();
filtered = filtered.Where(e =>
e.LeagueId.Contains(term, StringComparison.OrdinalIgnoreCase) ||
e.Side1Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
e.Side2Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
e.Category.Contains(term, StringComparison.OrdinalIgnoreCase));
}
var sorted = ApplySort(filtered, filter.SortKey, filter.SortDescending);
var materialized = sorted.ToList();
// Read each event's latest matching snapshot to populate the preview odds.
var rangeFrom = filter.Dates.From.AddDays(-2);
var rangeTo = filter.Dates.To.AddDays(2);
var rows = new List<EventListItem>(materialized.Count);
foreach (var ev in materialized)
{
ct.ThrowIfCancellationRequested();
var snapshots = await _snapshots
.ListByEventAsync(ev.Id, rangeFrom, rangeTo, ct)
.ConfigureAwait(false);
var matching = snapshots
.Where(s => s.Source == source)
.OrderByDescending(static s => s.CapturedAt)
.FirstOrDefault();
rows.Add(MapRow(ev, matching));
}
return rows;
}
private static IEnumerable<Event> ApplySort(IEnumerable<Event> source, EventSortKey key, bool desc)
=> key switch
{
EventSortKey.Sport => desc
? source.OrderByDescending(static e => e.Sport.Value)
: source.OrderBy(static e => e.Sport.Value),
EventSortKey.Country => desc
? source.OrderByDescending(static e => e.CountryCode, StringComparer.OrdinalIgnoreCase)
: source.OrderBy(static e => e.CountryCode, StringComparer.OrdinalIgnoreCase),
EventSortKey.League => desc
? source.OrderByDescending(static e => e.LeagueId, StringComparer.OrdinalIgnoreCase)
: source.OrderBy(static e => e.LeagueId, StringComparer.OrdinalIgnoreCase),
_ => desc
? source.OrderByDescending(static e => e.ScheduledAt)
: source.OrderBy(static e => e.ScheduledAt),
};
private static EventListItem MapRow(Event ev, OddsSnapshot? snapshot)
{
decimal? win1 = null, drw = null, win2 = null;
if (snapshot is not null)
{
(win1, drw, win2) = ExtractMatchWinRates(snapshot);
}
return new EventListItem(
ev.Id,
ev.Sport,
ev.CountryCode,
ev.LeagueId,
ev.Side1Name,
ev.Side2Name,
ev.ScheduledAt,
win1,
drw,
win2,
snapshot?.CapturedAt,
snapshot?.Source);
}
private static (decimal? Win1, decimal? Draw, decimal? Win2) ExtractMatchWinRates(OddsSnapshot s)
{
decimal? w1 = null, dr = null, w2 = null;
foreach (var bet in s.Bets)
{
if (bet.Scope is not MatchScope) continue;
switch (bet.Type)
{
case BetType.Win when bet.Side == Side.Side1:
w1 = bet.Rate.Value;
break;
case BetType.Win when bet.Side == Side.Side2:
w2 = bet.Rate.Value;
break;
case BetType.Draw:
dr = bet.Rate.Value;
break;
}
}
return (w1, dr, w2);
}
private static IReadOnlyList<EventScopeBoard> BuildBoards(OddsSnapshot snapshot)
{
// Group by scope, preserve Match-first order then ascending Period numbers.
var groups = snapshot.Bets
.GroupBy(static b => b.Scope, ScopeEqualityComparer.Instance)
.OrderBy(static g => OrderKey(g.Key));
var boards = new List<EventScopeBoard>();
foreach (var grp in groups)
{
var rows = grp
.OrderBy(static b => (int)b.Type)
.ThenBy(static b => (int)b.Side)
.ThenBy(static b => b.Value?.Value ?? 0m)
.Select(static b => new BetRow(b.Type, b.Side, b.Value?.Value, b.Rate.Value))
.ToList();
boards.Add(new EventScopeBoard(grp.Key, rows));
}
return boards;
}
private static OddsTimelinePoint BuildTimelinePoint(OddsSnapshot s)
{
var (w1, dr, w2) = ExtractMatchWinRates(s);
return new OddsTimelinePoint(s.CapturedAt, w1, dr, w2);
}
private static SnapshotHistoryEntry BuildHistoryEntry(OddsSnapshot s)
{
var (w1, dr, w2) = ExtractMatchWinRates(s);
return new SnapshotHistoryEntry(s.CapturedAt, s.Source, s.Bets.Count, w1, dr, w2);
}
private static int OrderKey(BetScope s) => s switch
{
MatchScope => 0,
PeriodScope p => p.Number,
_ => int.MaxValue,
};
private sealed class ScopeEqualityComparer : IEqualityComparer<BetScope>
{
public static readonly ScopeEqualityComparer Instance = new();
public bool Equals(BetScope? x, BetScope? y) => (x, y) switch
{
(null, null) => true,
(MatchScope, MatchScope) => true,
(PeriodScope a, PeriodScope b) => a.Number == b.Number,
_ => false,
};
public int GetHashCode(BetScope obj) => obj switch
{
MatchScope => 0,
PeriodScope p => p.Number,
_ => -1,
};
}
}
@@ -0,0 +1,91 @@
namespace Marathon.UI.Services;
/// <summary>
/// Page-scoped filter state for the event browsing pages. Pages bind to this
/// directly; navigating away and back restores the previous filter set within
/// the same circuit. No persistence — these are working filters, not user
/// settings.
/// </summary>
/// <remarks>
/// Registered as a singleton inside the RCL — for the WPF BlazorWebView host
/// the singleton is the entire circuit. A future ASP.NET Core Blazor Server
/// host should register it Scoped so each circuit gets its own copy.
/// </remarks>
public sealed class EventBrowsingState
{
private PageFilter _preMatch = PageFilter.Default(DateTime.UtcNow);
private PageFilter _live = PageFilter.Default(DateTime.UtcNow);
public PageFilter PreMatch => _preMatch;
public PageFilter Live => _live;
public event Action? OnChange;
public void UpdatePreMatch(PageFilter next)
{
ArgumentNullException.ThrowIfNull(next);
if (_preMatch.Equals(next)) return;
_preMatch = next;
OnChange?.Invoke();
}
public void UpdateLive(PageFilter next)
{
ArgumentNullException.ThrowIfNull(next);
if (_live.Equals(next)) return;
_live = next;
OnChange?.Invoke();
}
/// <summary>
/// Persistent (per-page) filter values that survive navigation within
/// the same circuit. Immutable record — pages produce new instances.
/// </summary>
public sealed record PageFilter(
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<int> SportCodes,
IReadOnlyList<string> CountryCodes,
string SearchTerm,
EventSortKey SortKey,
bool SortDescending)
{
public static PageFilter Default(DateTime nowUtc)
{
// Default window: -1d .. +7d in Moscow time, the same TZ events use.
var moscow = TimeSpan.FromHours(3);
var midnight = new DateTimeOffset(nowUtc.Date, TimeSpan.Zero).ToOffset(moscow);
return new PageFilter(
From: midnight.AddDays(-1),
To: midnight.AddDays(7),
SportCodes: Array.Empty<int>(),
CountryCodes: Array.Empty<string>(),
SearchTerm: string.Empty,
SortKey: EventSortKey.ScheduledAt,
SortDescending: false);
}
public EventFilter ToFilter() => new(
new DateRangeFilter(From, To),
SportCodes,
CountryCodes,
SearchTerm,
SortKey,
SortDescending);
public bool Equals(PageFilter? other)
{
if (other is null) return false;
return From == other.From
&& To == other.To
&& SortKey == other.SortKey
&& SortDescending == other.SortDescending
&& string.Equals(SearchTerm, other.SearchTerm, StringComparison.Ordinal)
&& SportCodes.SequenceEqual(other.SportCodes)
&& CountryCodes.SequenceEqual(other.CountryCodes, StringComparer.OrdinalIgnoreCase);
}
public override int GetHashCode() => HashCode.Combine(From, To, SortKey, SortDescending, SearchTerm);
}
}
+102
View File
@@ -0,0 +1,102 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// Compact event row used by the pre-match and live list pages.
/// View-model — DOES NOT carry mutable EF tracked references; the UI
/// is decoupled from domain mutability.
/// </summary>
public sealed record EventListItem(
EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Side1Name,
string Side2Name,
DateTimeOffset ScheduledAt,
decimal? Win1Rate,
decimal? DrawRate,
decimal? Win2Rate,
DateTimeOffset? LastSnapshotAt,
OddsSource? LastSnapshotSource);
/// <summary>
/// Snapshot of the bet board for a single event scope (Match or PeriodN).
/// </summary>
public sealed record EventScopeBoard(
BetScope Scope,
IReadOnlyList<BetRow> Bets);
/// <summary>
/// Single row in the per-scope bet board.
/// </summary>
public sealed record BetRow(
BetType Type,
Side Side,
decimal? Threshold,
decimal Rate);
/// <summary>
/// Full event detail aggregate for the detail page.
/// </summary>
public sealed record EventDetail(
EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Side1Name,
string Side2Name,
DateTimeOffset ScheduledAt,
IReadOnlyList<EventScopeBoard> Boards,
IReadOnlyList<OddsTimelinePoint> Timeline,
IReadOnlyList<SnapshotHistoryEntry> History);
/// <summary>
/// Single point in the odds-over-time chart — Win-1/Draw/Win-2 rates at a moment.
/// </summary>
public sealed record OddsTimelinePoint(
DateTimeOffset At,
decimal? Win1Rate,
decimal? DrawRate,
decimal? Win2Rate);
/// <summary>
/// Single row in the snapshot history table on the detail page.
/// </summary>
public sealed record SnapshotHistoryEntry(
DateTimeOffset CapturedAt,
OddsSource Source,
int BetCount,
decimal? Win1Rate,
decimal? DrawRate,
decimal? Win2Rate);
/// <summary>
/// Filter state passed from the page to the service. All members optional —
/// a request with everything null returns an unfiltered slice.
/// </summary>
public sealed record EventFilter(
DateRangeFilter Dates,
IReadOnlyCollection<int>? SportCodes = null,
IReadOnlyCollection<string>? CountryCodes = null,
string? SearchTerm = null,
EventSortKey SortKey = EventSortKey.ScheduledAt,
bool SortDescending = false,
OddsSource? Source = null);
/// <summary>
/// Inclusive [From..To] date filter; collapses to a 7-day window when
/// both members are null.
/// </summary>
public sealed record DateRangeFilter(DateTimeOffset From, DateTimeOffset To);
/// <summary>The columns the user can sort by on the list pages.</summary>
public enum EventSortKey
{
ScheduledAt,
Sport,
Country,
League,
}
@@ -0,0 +1,27 @@
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Read-only browsing facade over the Event/Snapshot repositories.
/// Pages depend on this — never on <c>IEventRepository</c> directly — so
/// view-model shaping stays in one place and the UI does not bind to mutable
/// EF graphs.
/// </summary>
public interface IEventBrowsingService
{
/// <summary>List upcoming (pre-match) events matching the filter.</summary>
Task<IReadOnlyList<EventListItem>> ListUpcomingAsync(EventFilter filter, CancellationToken ct);
/// <summary>List currently-live events with their most recent live snapshot odds.</summary>
Task<IReadOnlyList<EventListItem>> ListLiveAsync(EventFilter filter, CancellationToken ct);
/// <summary>Full detail aggregate for the event detail page; null when the event is not found.</summary>
Task<EventDetail?> GetDetailAsync(DomainEventId eventId, CancellationToken ct);
/// <summary>The set of distinct sport codes present in the data — used to populate the filter chips.</summary>
Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct);
/// <summary>The set of distinct country codes present in the data — used to populate the filter chips.</summary>
Task<IReadOnlyList<string>> ListKnownCountryCodesAsync(CancellationToken ct);
}
@@ -45,6 +45,10 @@ public static class UiServicesExtensions
// Singletons that drive UI chrome state.
services.AddSingleton<ThemeState>();
services.AddSingleton<LocaleState>();
services.AddSingleton<EventBrowsingState>();
// Browsing facade — Scoped so it captures the per-circuit repository scope.
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));