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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user