Files
maraphon-app/src/Marathon.UI/Pages/Results/ResultsLoader.razor
T
alexei.dolgolyov 1e4dddbbad feat(ui): Velocity rollout — page polish + lime-as-text contrast fixes
Roll the re-skin across the remaining surfaces and fix the readability
regressions the lime accent introduced (lime works as a fill/border but is
unreadable as text on light):

- --m-c-rule is now a soft divider, so page panels/tables get tidy outlines
  instead of a mess of black hairlines; the brutalist weight stays on cards,
  nav, sections and inputs (which reference ink directly).
- New --m-c-warning (amber) for medium severity, keeping the low→medium→high
  gradient legible; applied to SeverityBadge, AnomalyCard, feed stat.
- Interactive/link/highlight text (Home CTA + links, Journal/Backtest/Compare
  buttons, KPI + evidence values) moved off lime to the readable --m-c-info
  blue; Home first-run CTA is now a filled-lime brutalist button; odds-up
  delta → positive green; rate arrow → neutral.
- Results winner colours → tokens (positive / info) + Velocity-aligned tints.

CSS-only — build clean, all 568 tests green.
2026-05-29 15:04:15 +03:00

411 lines
16 KiB
Plaintext

@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(31,158,61,0.10); color: var(--m-c-positive); border-color: rgba(31,158,61,0.32); }
.m-result-winner--side2 { background: rgba(36,75,255,0.10); color: var(--m-c-info); border-color: rgba(36,75,255,0.32); }
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
</style>
@code {
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(MoscowTime.Offset);
_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, MoscowTime.Offset);
await ReloadCandidatesAsync();
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_to = new DateTimeOffset(v.Date, MoscowTime.Offset).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) => SportLabels.Resolve(L, code);
private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
}
}