1e4dddbbad
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.
411 lines
16 KiB
Plaintext
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();
|
|
}
|
|
}
|