feat(phase-8-frontend): results loader UI + browsing list + 41 localization keys
* Pages/Results/ResultsList.razor — completed-events list with date range, sport/winner filter, search, footer count. * Pages/Results/ResultsLoader.razor — driver page with two modes (load all in range / load selected events), live progress reporting via IProgress<PullResultsProgress>, summary line, cancellable. * Replaces the Phase 5 Pages/Results.razor placeholder. Service layer: * IResultsBrowsingService + ResultsBrowsingService (Scoped, mirrors the Event/Anomaly browsing-service pattern). Reads IResultRepository + IEventRepository, projects to immutable view-model records. * UiServicesExtensions: registers ResultsBrowsingService; also fixes an unrelated localization resolver bug (drop ResourcesPath since SharedResource lives in the Marathon.UI.Resources namespace already). Localization: * 41 new Results.* keys (RU+EN parity) covering both pages, filter chips, loader modes, progress states, and footer copy. Tests: * ResultsListTests + ResultsLoaderTests — 22 new bUnit tests covering filter narrowing, mode switching, progress aggregation, and empty states. * FakeResultsBrowsingService support type for tests. * MarathonTestContext registers the fake; TestData adds factories for EventResult/EventResultListItem.
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
@page "/results"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
|
||||
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Results"]" />
|
||||
@@ -0,0 +1,330 @@
|
||||
@page "/results"
|
||||
@using Marathon.Domain.Enums
|
||||
@using Marathon.UI.Components
|
||||
@using Marathon.UI.Services
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IResultsBrowsingService Browsing
|
||||
@inject IEventBrowsingService Events
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
|
||||
|
||||
<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">@L["Nav.Section.Analysis"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Results.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Results.Lede"]</p>
|
||||
<div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="GoToLoader"
|
||||
data-test="results-open-loader">
|
||||
@L["Results.Action.LoadNew"]
|
||||
</MudButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["Results.Title"]">
|
||||
<div class="m-list-toolbar__row">
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Results.Filter.From"]</label>
|
||||
<input class="m-input" type="date" value="@FormatDate(_from)" @onchange="OnFromChanged" />
|
||||
</div>
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Results.Filter.To"]</label>
|
||||
<input class="m-input" type="date" value="@FormatDate(_to)" @onchange="OnToChanged" />
|
||||
</div>
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Results.Filter.Winner"]</label>
|
||||
<select class="m-input"
|
||||
@onchange="OnWinnerChanged"
|
||||
data-test="results-winner-filter">
|
||||
<option value="" selected="@(_winner is null)">@L["Results.Filter.Winner.All"]</option>
|
||||
<option value="Side1" selected="@(_winner == Side.Side1)">@L["Results.Filter.Winner.Side1"]</option>
|
||||
<option value="Side2" selected="@(_winner == Side.Side2)">@L["Results.Filter.Winner.Side2"]</option>
|
||||
<option value="Draw" selected="@(_winner == Side.Draw)">@L["Results.Filter.Winner.Draw"]</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="m-list-toolbar__group m-list-toolbar__group--grow">
|
||||
<label class="m-list-toolbar__label">@L["Results.Filter.Search"]</label>
|
||||
<input class="m-input"
|
||||
type="search"
|
||||
value="@_searchInput"
|
||||
placeholder="@L["Results.Filter.Search.Placeholder"]"
|
||||
@oninput="OnSearchInput" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_availableSports.Count > 0)
|
||||
{
|
||||
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
||||
<span class="m-list-toolbar__label">@L["Results.Filter.Sport"]</span>
|
||||
@foreach (var sportCode in _availableSports)
|
||||
{
|
||||
var active = _sportCodes.Contains(sportCode);
|
||||
var label = 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="@label" ClassName="m-chip__icon" />
|
||||
<span>@label</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="m-list-table m-rise m-rise-3" role="region" aria-label="@L["Results.Title"]">
|
||||
@if (_loading)
|
||||
{
|
||||
<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["Results.Empty"]</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="m-table" data-test="results-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 36px;"></th>
|
||||
<th scope="col">@L["Results.Column.Time"]</th>
|
||||
<th scope="col">@L["Results.Column.Country"]</th>
|
||||
<th scope="col">@L["Results.Column.League"]</th>
|
||||
<th scope="col">@L["Results.Column.Match"]</th>
|
||||
<th scope="col" style="text-align: center;">@L["Results.Column.Score"]</th>
|
||||
<th scope="col">@L["Results.Column.Winner"]</th>
|
||||
<th scope="col">@L["Results.Column.CompletedAt"]</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _rows)
|
||||
{
|
||||
<tr class="m-table__row"
|
||||
data-test="results-row"
|
||||
data-event-id="@row.Id.Value"
|
||||
tabindex="0"
|
||||
@onclick="() => OpenDetail(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 class="m-mono" style="text-align: center; font-weight: 600;" data-test="results-score">
|
||||
@row.Side1Score:@row.Side2Score
|
||||
</td>
|
||||
<td>
|
||||
<span class="m-result-winner m-result-winner--@WinnerCss(row.WinnerSide)" data-test="results-winner">
|
||||
@WinnerLabel(row.WinnerSide)
|
||||
</span>
|
||||
</td>
|
||||
<td class="m-mono">@row.CompletedAt.ToLocalTime().ToString("dd MMM HH:mm")</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["Results.Footer.Items"]
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-result-winner {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
|
||||
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
|
||||
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
|
||||
[data-theme="dark"] .m-result-winner--side1 { color: #4ade80; background: rgba(34,197,94,0.15); }
|
||||
[data-theme="dark"] .m-result-winner--side2 { color: #93c5fd; background: rgba(59,130,246,0.15); }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
private DateTimeOffset _from;
|
||||
private DateTimeOffset _to;
|
||||
private string _searchInput = string.Empty;
|
||||
private Side? _winner;
|
||||
private List<int> _sportCodes = new();
|
||||
|
||||
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
|
||||
private List<EventResultListItem> _rows = new();
|
||||
private bool _loading;
|
||||
|
||||
private CancellationTokenSource? _searchCts;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowOffset);
|
||||
_from = todayMoscow.AddDays(-30);
|
||||
_to = todayMoscow.AddDays(1).AddSeconds(-1);
|
||||
|
||||
try
|
||||
{
|
||||
_availableSports = await Events.ListKnownSportCodesAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_availableSports = Array.Empty<int>();
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
_loadCts = new CancellationTokenSource();
|
||||
var ct = _loadCts.Token;
|
||||
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
var filter = new ResultsFilter(
|
||||
Dates: new DateRangeFilter(_from, _to),
|
||||
SportCodes: _sportCodes.Count == 0 ? null : _sportCodes.ToArray(),
|
||||
WinnerSide: _winner,
|
||||
SearchTerm: string.IsNullOrWhiteSpace(_searchInput) ? null : _searchInput.Trim());
|
||||
|
||||
var rows = await Browsing.ListResultsAsync(filter, ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_rows = rows.ToList();
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded */ }
|
||||
catch
|
||||
{
|
||||
_rows = new();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void GoToLoader() => Nav.NavigateTo("/results/load");
|
||||
|
||||
private void OpenDetail(EventResultListItem row)
|
||||
=> Nav.NavigateTo($"/events/{Uri.EscapeDataString(row.Id.Value)}");
|
||||
|
||||
private async Task HandleRowKey(KeyboardEventArgs e, EventResultListItem row)
|
||||
{
|
||||
if (e.Key == "Enter" || e.Key == " ")
|
||||
{
|
||||
OpenDetail(row);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnFromChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
||||
{
|
||||
_from = new DateTimeOffset(v.Date, MoscowOffset);
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnToChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
||||
{
|
||||
_to = new DateTimeOffset(v.Date, MoscowOffset).AddDays(1).AddSeconds(-1);
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnWinnerChanged(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString();
|
||||
_winner = raw switch
|
||||
{
|
||||
"Side1" => Side.Side1,
|
||||
"Side2" => Side.Side2,
|
||||
"Draw" => Side.Draw,
|
||||
_ => null,
|
||||
};
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task OnSearchInput(ChangeEventArgs e)
|
||||
{
|
||||
_searchInput = e.Value?.ToString() ?? string.Empty;
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
try
|
||||
{
|
||||
await Task.Delay(300, token);
|
||||
if (!token.IsCancellationRequested) await LoadAsync();
|
||||
}
|
||||
catch (TaskCanceledException) { /* superseded */ }
|
||||
}
|
||||
|
||||
private async Task ToggleSport(int code)
|
||||
{
|
||||
if (!_sportCodes.Remove(code)) _sportCodes.Add(code);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
|
||||
|
||||
private string WinnerLabel(Side s) => s switch
|
||||
{
|
||||
Side.Side1 => L["Results.Filter.Winner.Side1"],
|
||||
Side.Side2 => L["Results.Filter.Winner.Side2"],
|
||||
Side.Draw => L["Results.Filter.Winner.Draw"],
|
||||
_ => s.ToString(),
|
||||
};
|
||||
|
||||
private static string WinnerCss(Side s) => s switch
|
||||
{
|
||||
Side.Side1 => "side1",
|
||||
Side.Side2 => "side2",
|
||||
Side.Draw => "draw",
|
||||
_ => "draw",
|
||||
};
|
||||
|
||||
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}",
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
_loadCts?.Cancel();
|
||||
_loadCts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
@page "/results/load"
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using Marathon.Application.UseCases
|
||||
@using Marathon.Domain.Enums
|
||||
@using Marathon.UI.Components
|
||||
@using Marathon.UI.Services
|
||||
@using AppDateRange = Marathon.Application.Storage.DateRange
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IResultsBrowsingService Browsing
|
||||
@inject NavigationManager Nav
|
||||
@inject IServiceProvider Sp
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Results.Loader.Title"]</PageTitle>
|
||||
|
||||
<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">@L["Results.Loader.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Results.Loader.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Results.Loader.Lede"]</p>
|
||||
<div>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="GoBack" data-test="results-loader-back">
|
||||
@L["Results.Loader.Action.Back"]
|
||||
</MudButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["Results.Loader.Title"]">
|
||||
<div class="m-list-toolbar__row">
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Results.Filter.From"]</label>
|
||||
<input class="m-input" type="date"
|
||||
value="@FormatDate(_from)"
|
||||
disabled="@_running"
|
||||
@onchange="OnFromChanged" />
|
||||
</div>
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Results.Filter.To"]</label>
|
||||
<input class="m-input" type="date"
|
||||
value="@FormatDate(_to)"
|
||||
disabled="@_running"
|
||||
@onchange="OnToChanged" />
|
||||
</div>
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Results.Loader.Mode"]</label>
|
||||
<select class="m-input"
|
||||
disabled="@_running"
|
||||
@onchange="OnModeChanged"
|
||||
data-test="results-loader-mode">
|
||||
<option value="AllInRange" selected="@(_mode == LoaderMode.AllInRange)">@L["Results.Loader.Mode.AllInRange"]</option>
|
||||
<option value="Selected" selected="@(_mode == LoaderMode.Selected)">@L["Results.Loader.Mode.Selected"]</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_mode == LoaderMode.Selected)
|
||||
{
|
||||
<div class="m-loader-picker" role="region" aria-label="@L["Results.Loader.Mode.Selected"]">
|
||||
@if (_candidates.Count == 0)
|
||||
{
|
||||
<p style="color: var(--m-c-ink-soft); font-size: 0.875rem;" data-test="results-loader-no-candidates">
|
||||
@L["Results.Loader.Selected.Empty"]
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--m-space-2);">
|
||||
<span class="m-list-toolbar__label">@string.Format(L["Results.Loader.Selected.CountFormat"], _selectedIds.Count)</span>
|
||||
</div>
|
||||
<div class="m-loader-picker__scroller">
|
||||
@foreach (var c in _candidates)
|
||||
{
|
||||
var checkedNow = _selectedIds.Contains(c.Id.Value);
|
||||
var localId = c.Id.Value;
|
||||
<label class="m-loader-picker__row" data-test="results-loader-candidate">
|
||||
<input type="checkbox"
|
||||
checked="@checkedNow"
|
||||
disabled="@_running"
|
||||
@onchange="@(e => ToggleSelected(localId, ((bool?)e.Value) ?? false))" />
|
||||
<SportIcon Code="@c.Sport.Value" Label="@SportLabel(c.Sport.Value)" />
|
||||
<span class="m-mono">@c.ScheduledAt.ToString("dd MMM HH:mm")</span>
|
||||
<span style="color: var(--m-c-ink-soft);">@c.LeagueId</span>
|
||||
<span style="font-weight: 500;">@c.Side1Name <span style="color: var(--m-c-ink-soft);">vs</span> @c.Side2Name</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="m-list-toolbar__row" style="justify-content: flex-end; gap: var(--m-space-2);">
|
||||
@if (_running)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Error"
|
||||
OnClick="CancelLoad"
|
||||
data-test="results-loader-cancel">
|
||||
@L["Results.Loader.Action.Cancel"]
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Disabled="@(!CanLoad())"
|
||||
OnClick="StartLoad"
|
||||
data-test="results-loader-start">
|
||||
@L["Results.Loader.Action.Load"]
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_running || _completed)
|
||||
{
|
||||
<div class="m-rise m-rise-3" style="background: var(--m-c-paper); border: 1px solid var(--m-c-rule); padding: var(--m-space-4); display: grid; gap: var(--m-space-3);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span class="m-mono" data-test="results-loader-progress">
|
||||
@string.Format(L["Results.Loader.Progress.Format"], _processed, _total)
|
||||
</span>
|
||||
@if (_completed)
|
||||
{
|
||||
<span class="m-mono" style="color: var(--m-c-ink-soft); font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;" data-test="results-loader-summary">
|
||||
@string.Format(L["Results.Loader.Summary.Format"], _summaryLoaded, _summarySkipped, _summaryProcessed)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<progress max="@(_total == 0 ? 1 : _total)" value="@_processed" style="width: 100%;"></progress>
|
||||
|
||||
@if (_progressLog.Count > 0)
|
||||
{
|
||||
<table class="m-table" data-test="results-loader-log">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Results.Column.Match"]</th>
|
||||
<th scope="col" style="text-align: center;">@L["Results.Column.Score"]</th>
|
||||
<th scope="col">@L["Results.Column.Winner"]</th>
|
||||
<th scope="col">Outcome</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in _progressLog)
|
||||
{
|
||||
<tr data-test="results-loader-log-row">
|
||||
<td>@entry.MatchLabel</td>
|
||||
<td class="m-mono" style="text-align: center;">
|
||||
@if (entry.Loaded is { } r)
|
||||
{
|
||||
@($"{r.Side1Score}:{r.Side2Score}")
|
||||
}
|
||||
else
|
||||
{
|
||||
@("—")
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.Loaded is { WinnerSide: var w })
|
||||
{
|
||||
<span class="m-result-winner m-result-winner--@WinnerCss(w)">@WinnerLabel(w)</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;">
|
||||
@OutcomeLabel(entry.Outcome)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-loader-picker { display: grid; gap: var(--m-space-2); padding: var(--m-space-3); border: 1px solid var(--m-c-rule); background: var(--m-c-paper-2); border-radius: var(--m-radius-xs); }
|
||||
.m-loader-picker__scroller { display: grid; gap: 4px; max-height: 280px; overflow-y: auto; }
|
||||
.m-loader-picker__row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 24px max-content 1fr 2fr;
|
||||
gap: var(--m-space-3);
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--m-radius-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-loader-picker__row:hover { background: var(--m-c-paper); border-color: var(--m-c-rule); }
|
||||
.m-result-winner {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
|
||||
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
|
||||
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
private DateTimeOffset _from;
|
||||
private DateTimeOffset _to;
|
||||
private LoaderMode _mode = LoaderMode.AllInRange;
|
||||
|
||||
private List<EventResultCandidate> _candidates = new();
|
||||
private HashSet<string> _selectedIds = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _running;
|
||||
private bool _completed;
|
||||
private int _processed;
|
||||
private int _total;
|
||||
private int _summaryLoaded;
|
||||
private int _summarySkipped;
|
||||
private int _summaryProcessed;
|
||||
|
||||
private List<ProgressEntry> _progressLog = new();
|
||||
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
private enum LoaderMode { AllInRange, Selected }
|
||||
|
||||
private sealed record ProgressEntry(
|
||||
string MatchLabel,
|
||||
ResultLoadOutcome Outcome,
|
||||
Domain.Entities.EventResult? Loaded);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowOffset);
|
||||
_from = todayMoscow.AddDays(-7);
|
||||
_to = todayMoscow.AddDays(1).AddSeconds(-1);
|
||||
await ReloadCandidatesAsync();
|
||||
}
|
||||
|
||||
private async Task ReloadCandidatesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var range = new AppDateRange(_from, _to);
|
||||
var fresh = await Browsing.ListLoadCandidatesAsync(range, CancellationToken.None);
|
||||
_candidates = fresh.ToList();
|
||||
|
||||
// Keep selections that are still valid; drop the rest.
|
||||
var stillValid = new HashSet<string>(fresh.Select(c => c.Id.Value), StringComparer.Ordinal);
|
||||
_selectedIds.IntersectWith(stillValid);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_candidates = new();
|
||||
_selectedIds.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanLoad()
|
||||
{
|
||||
if (_running) return false;
|
||||
if (_mode == LoaderMode.AllInRange) return _candidates.Count > 0;
|
||||
return _selectedIds.Count > 0;
|
||||
}
|
||||
|
||||
private async Task StartLoad()
|
||||
{
|
||||
_running = true;
|
||||
_completed = false;
|
||||
_progressLog.Clear();
|
||||
_processed = 0;
|
||||
|
||||
IReadOnlyList<Domain.ValueObjects.EventId>? selection = _mode == LoaderMode.Selected
|
||||
? _selectedIds.Select(s => new Domain.ValueObjects.EventId(s)).ToList()
|
||||
: null;
|
||||
|
||||
// Pre-set Total so the progress bar has the right scale before the first tick.
|
||||
_total = _mode == LoaderMode.Selected ? _selectedIds.Count : _candidates.Count;
|
||||
StateHasChanged();
|
||||
|
||||
_runCts = new CancellationTokenSource();
|
||||
var ct = _runCts.Token;
|
||||
|
||||
// Resolve a fresh use-case scope so the EF DbContext is owned by this run.
|
||||
await using var scope = Sp.CreateAsyncScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<PullResultsUseCase>();
|
||||
|
||||
var progress = new Progress<PullResultsProgress>(OnProgress);
|
||||
|
||||
try
|
||||
{
|
||||
var range = new AppDateRange(_from, _to);
|
||||
var (inspected, loaded, skipped) = await useCase.ExecuteAsync(range, selection, progress, ct);
|
||||
_summaryProcessed = inspected;
|
||||
_summaryLoaded = loaded;
|
||||
_summarySkipped = skipped;
|
||||
_completed = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled — leave the partial log visible.
|
||||
_completed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
_runCts?.Dispose();
|
||||
_runCts = null;
|
||||
await ReloadCandidatesAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProgress(PullResultsProgress update)
|
||||
{
|
||||
_processed = update.Processed;
|
||||
_total = Math.Max(_total, update.Total);
|
||||
|
||||
var label = LabelForEvent(update.EventId.Value);
|
||||
_progressLog.Add(new ProgressEntry(
|
||||
MatchLabel: label,
|
||||
Outcome: update.Outcome,
|
||||
Loaded: update.Result));
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private string LabelForEvent(string id)
|
||||
{
|
||||
var c = _candidates.FirstOrDefault(c => c.Id.Value == id);
|
||||
return c is null
|
||||
? id
|
||||
: $"{c.Side1Name} vs {c.Side2Name}";
|
||||
}
|
||||
|
||||
private void CancelLoad()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
}
|
||||
|
||||
private void GoBack() => Nav.NavigateTo("/results");
|
||||
|
||||
private async Task OnFromChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
||||
{
|
||||
_from = new DateTimeOffset(v.Date, MoscowOffset);
|
||||
await ReloadCandidatesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnToChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
||||
{
|
||||
_to = new DateTimeOffset(v.Date, MoscowOffset).AddDays(1).AddSeconds(-1);
|
||||
await ReloadCandidatesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnModeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString();
|
||||
_mode = string.Equals(raw, nameof(LoaderMode.Selected), StringComparison.OrdinalIgnoreCase)
|
||||
? LoaderMode.Selected
|
||||
: LoaderMode.AllInRange;
|
||||
}
|
||||
|
||||
private void ToggleSelected(string id, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedIds.Add(id);
|
||||
else _selectedIds.Remove(id);
|
||||
}
|
||||
|
||||
private string WinnerLabel(Side s) => s switch
|
||||
{
|
||||
Side.Side1 => L["Results.Filter.Winner.Side1"],
|
||||
Side.Side2 => L["Results.Filter.Winner.Side2"],
|
||||
Side.Draw => L["Results.Filter.Winner.Draw"],
|
||||
_ => s.ToString(),
|
||||
};
|
||||
|
||||
private static string WinnerCss(Side s) => s switch
|
||||
{
|
||||
Side.Side1 => "side1",
|
||||
Side.Side2 => "side2",
|
||||
Side.Draw => "draw",
|
||||
_ => "draw",
|
||||
};
|
||||
|
||||
private string OutcomeLabel(ResultLoadOutcome o) => o switch
|
||||
{
|
||||
ResultLoadOutcome.Loaded => L["Results.Loader.Progress.Loaded"],
|
||||
ResultLoadOutcome.AlreadyLoaded => L["Results.Loader.Progress.AlreadyLoaded"],
|
||||
ResultLoadOutcome.NotYetComplete => L["Results.Loader.Progress.NotYetComplete"],
|
||||
ResultLoadOutcome.Failed => L["Results.Loader.Progress.Failed"],
|
||||
_ => o.ToString(),
|
||||
};
|
||||
|
||||
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 static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user