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();
|
||||
}
|
||||
}
|
||||
@@ -258,4 +258,47 @@
|
||||
<data name="Sport.Football"><value>Football</value></data>
|
||||
<data name="Sport.Tennis"><value>Tennis</value></data>
|
||||
<data name="Sport.Hockey"><value>Hockey</value></data>
|
||||
|
||||
<data name="Results.Title"><value>Match results</value></data>
|
||||
<data name="Results.Lede"><value>Final scores of loaded events. We walk each event page, wait for matchIsComplete=true, and record the winning side.</value></data>
|
||||
<data name="Results.Action.LoadNew"><value>Load results</value></data>
|
||||
<data name="Results.Action.OpenList"><value>Back to list</value></data>
|
||||
<data name="Results.Filter.From"><value>From</value></data>
|
||||
<data name="Results.Filter.To"><value>To</value></data>
|
||||
<data name="Results.Filter.Search"><value>Search</value></data>
|
||||
<data name="Results.Filter.Search.Placeholder"><value>Team, league, category…</value></data>
|
||||
<data name="Results.Filter.Sport"><value>Sport</value></data>
|
||||
<data name="Results.Filter.Winner"><value>Winner</value></data>
|
||||
<data name="Results.Filter.Winner.All"><value>Any</value></data>
|
||||
<data name="Results.Filter.Winner.Side1"><value>Side 1</value></data>
|
||||
<data name="Results.Filter.Winner.Side2"><value>Side 2</value></data>
|
||||
<data name="Results.Filter.Winner.Draw"><value>Draw</value></data>
|
||||
<data name="Results.Column.Time"><value>Time</value></data>
|
||||
<data name="Results.Column.Country"><value>Country</value></data>
|
||||
<data name="Results.Column.League"><value>League</value></data>
|
||||
<data name="Results.Column.Match"><value>Match</value></data>
|
||||
<data name="Results.Column.Score"><value>Score</value></data>
|
||||
<data name="Results.Column.Winner"><value>Winner</value></data>
|
||||
<data name="Results.Column.CompletedAt"><value>Completed</value></data>
|
||||
<data name="Results.Empty"><value>No results loaded for this range yet. Run a load or wait for matches to complete.</value></data>
|
||||
<data name="Results.Footer.Items"><value>results</value></data>
|
||||
|
||||
<data name="Results.Loader.Kicker"><value>Loader</value></data>
|
||||
<data name="Results.Loader.Title"><value>Load results</value></data>
|
||||
<data name="Results.Loader.Lede"><value>We poll each event page, capture the final score, and record the winning side. Pick a date range or specific events.</value></data>
|
||||
<data name="Results.Loader.Mode"><value>Mode</value></data>
|
||||
<data name="Results.Loader.Mode.AllInRange"><value>All in range</value></data>
|
||||
<data name="Results.Loader.Mode.Selected"><value>Selected events</value></data>
|
||||
<data name="Results.Loader.Selected.Empty"><value>Every event in this range already has a result.</value></data>
|
||||
<data name="Results.Loader.Selected.CountFormat"><value>{0} selected</value></data>
|
||||
<data name="Results.Loader.Action.Load"><value>Load</value></data>
|
||||
<data name="Results.Loader.Action.Cancel"><value>Cancel</value></data>
|
||||
<data name="Results.Loader.Action.Back"><value>Back</value></data>
|
||||
<data name="Results.Loader.Progress.Format"><value>{0} / {1}</value></data>
|
||||
<data name="Results.Loader.Progress.Loaded"><value>Loaded</value></data>
|
||||
<data name="Results.Loader.Progress.AlreadyLoaded"><value>Already loaded</value></data>
|
||||
<data name="Results.Loader.Progress.NotYetComplete"><value>Not yet complete</value></data>
|
||||
<data name="Results.Loader.Progress.Failed"><value>Failed</value></data>
|
||||
<data name="Results.Loader.Summary.Format"><value>Loaded {0}, skipped {1}, processed {2} total.</value></data>
|
||||
<data name="Results.Loader.Empty.NoCandidates"><value>No events to load in this range.</value></data>
|
||||
</root>
|
||||
|
||||
@@ -271,4 +271,47 @@
|
||||
<data name="Sport.Football"><value>Футбол</value></data>
|
||||
<data name="Sport.Tennis"><value>Теннис</value></data>
|
||||
<data name="Sport.Hockey"><value>Хоккей</value></data>
|
||||
|
||||
<data name="Results.Title"><value>Результаты матчей</value></data>
|
||||
<data name="Results.Lede"><value>Финальные счета загруженных событий. Обходим страницу события, ждём matchIsComplete=true, фиксируем сторону-победителя.</value></data>
|
||||
<data name="Results.Action.LoadNew"><value>Загрузить результаты</value></data>
|
||||
<data name="Results.Action.OpenList"><value>К списку</value></data>
|
||||
<data name="Results.Filter.From"><value>С</value></data>
|
||||
<data name="Results.Filter.To"><value>По</value></data>
|
||||
<data name="Results.Filter.Search"><value>Поиск</value></data>
|
||||
<data name="Results.Filter.Search.Placeholder"><value>Команда, лига, категория…</value></data>
|
||||
<data name="Results.Filter.Sport"><value>Спорт</value></data>
|
||||
<data name="Results.Filter.Winner"><value>Победитель</value></data>
|
||||
<data name="Results.Filter.Winner.All"><value>Любой</value></data>
|
||||
<data name="Results.Filter.Winner.Side1"><value>Команда 1</value></data>
|
||||
<data name="Results.Filter.Winner.Side2"><value>Команда 2</value></data>
|
||||
<data name="Results.Filter.Winner.Draw"><value>Ничья</value></data>
|
||||
<data name="Results.Column.Time"><value>Время</value></data>
|
||||
<data name="Results.Column.Country"><value>Страна</value></data>
|
||||
<data name="Results.Column.League"><value>Лига</value></data>
|
||||
<data name="Results.Column.Match"><value>Матч</value></data>
|
||||
<data name="Results.Column.Score"><value>Счёт</value></data>
|
||||
<data name="Results.Column.Winner"><value>Победитель</value></data>
|
||||
<data name="Results.Column.CompletedAt"><value>Завершено</value></data>
|
||||
<data name="Results.Empty"><value>Результатов в выбранном диапазоне ещё нет. Запустите загрузку или подождите завершения матчей.</value></data>
|
||||
<data name="Results.Footer.Items"><value>результатов</value></data>
|
||||
|
||||
<data name="Results.Loader.Kicker"><value>Загрузка</value></data>
|
||||
<data name="Results.Loader.Title"><value>Загрузить результаты</value></data>
|
||||
<data name="Results.Loader.Lede"><value>Опросим страницу каждого события, заберём финальный счёт и сторону-победителя. Выберите диапазон или конкретные события.</value></data>
|
||||
<data name="Results.Loader.Mode"><value>Режим</value></data>
|
||||
<data name="Results.Loader.Mode.AllInRange"><value>Все в диапазоне</value></data>
|
||||
<data name="Results.Loader.Mode.Selected"><value>Выбранные события</value></data>
|
||||
<data name="Results.Loader.Selected.Empty"><value>Все события в этом диапазоне уже имеют результат.</value></data>
|
||||
<data name="Results.Loader.Selected.CountFormat"><value>{0} выбрано</value></data>
|
||||
<data name="Results.Loader.Action.Load"><value>Загрузить</value></data>
|
||||
<data name="Results.Loader.Action.Cancel"><value>Отменить</value></data>
|
||||
<data name="Results.Loader.Action.Back"><value>Назад</value></data>
|
||||
<data name="Results.Loader.Progress.Format"><value>{0} / {1}</value></data>
|
||||
<data name="Results.Loader.Progress.Loaded"><value>Загружено</value></data>
|
||||
<data name="Results.Loader.Progress.AlreadyLoaded"><value>Уже было</value></data>
|
||||
<data name="Results.Loader.Progress.NotYetComplete"><value>Не завершено</value></data>
|
||||
<data name="Results.Loader.Progress.Failed"><value>Ошибка</value></data>
|
||||
<data name="Results.Loader.Summary.Format"><value>Загружено {0}, пропущено {1}, всего обработано {2}.</value></data>
|
||||
<data name="Results.Loader.Empty.NoCandidates"><value>Нет событий для загрузки в этом диапазоне.</value></data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only browsing facade over the EventResult / Event repositories.
|
||||
/// The Results pages depend on this — never directly on
|
||||
/// <c>IResultRepository</c> — so view-model shaping stays in one place.
|
||||
/// </summary>
|
||||
public interface IResultsBrowsingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists already-loaded results matching <paramref name="filter"/>.
|
||||
/// Joins the underlying <c>Event</c> rows so the UI can render team
|
||||
/// names, sport, and league without a second round-trip.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EventResultListItem>> ListResultsAsync(
|
||||
ResultsFilter filter,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Lists events scheduled in <paramref name="range"/> that DO NOT yet
|
||||
/// have a stored result — used to populate the "selected events"
|
||||
/// multi-select on the Results loader page.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EventResultCandidate>> ListLoadCandidatesAsync(
|
||||
DateRange range,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// View-model row shown on the Results list page.
|
||||
/// </summary>
|
||||
public sealed record EventResultListItem(
|
||||
Marathon.Domain.ValueObjects.EventId Id,
|
||||
SportCode Sport,
|
||||
string CountryCode,
|
||||
string LeagueId,
|
||||
string Side1Name,
|
||||
string Side2Name,
|
||||
DateTimeOffset ScheduledAt,
|
||||
int Side1Score,
|
||||
int Side2Score,
|
||||
Side WinnerSide,
|
||||
DateTimeOffset CompletedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Slim view-model for the loader's "selected events" picker.
|
||||
/// </summary>
|
||||
public sealed record EventResultCandidate(
|
||||
Marathon.Domain.ValueObjects.EventId Id,
|
||||
SportCode Sport,
|
||||
string CountryCode,
|
||||
string LeagueId,
|
||||
string Side1Name,
|
||||
string Side2Name,
|
||||
DateTimeOffset ScheduledAt);
|
||||
|
||||
/// <summary>
|
||||
/// Filter for the Results list page. <see cref="WinnerSide"/> = null means
|
||||
/// "any winner" (no filter); otherwise narrows to that outcome.
|
||||
/// </summary>
|
||||
public sealed record ResultsFilter(
|
||||
DateRangeFilter Dates,
|
||||
IReadOnlyCollection<int>? SportCodes = null,
|
||||
Side? WinnerSide = null,
|
||||
string? SearchTerm = null);
|
||||
@@ -0,0 +1,98 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Domain.Entities;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IResultsBrowsingService"/> backed by
|
||||
/// the event + result repositories. Reads the full slice into memory and
|
||||
/// shapes view-models — the page-level filter slices are small enough that
|
||||
/// in-memory composition is the simpler choice.
|
||||
/// </summary>
|
||||
public sealed class ResultsBrowsingService : IResultsBrowsingService
|
||||
{
|
||||
private readonly IEventRepository _events;
|
||||
private readonly IResultRepository _results;
|
||||
|
||||
public ResultsBrowsingService(IEventRepository events, IResultRepository results)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EventResultListItem>> ListResultsAsync(
|
||||
ResultsFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
var range = new DateRange(filter.Dates.From, filter.Dates.To);
|
||||
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
||||
var allResults = await _results.ListAsync(ct).ConfigureAwait(false);
|
||||
var resultsByEventId = allResults.ToDictionary(r => r.EventId.Value, r => r);
|
||||
|
||||
var rows = new List<EventResultListItem>();
|
||||
foreach (var ev in events)
|
||||
{
|
||||
if (!resultsByEventId.TryGetValue(ev.Id.Value, out var result))
|
||||
continue;
|
||||
|
||||
if (filter.SportCodes is { Count: > 0 } sports
|
||||
&& !sports.Contains(ev.Sport.Value))
|
||||
continue;
|
||||
|
||||
if (filter.WinnerSide is { } winner && result.WinnerSide != winner)
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
|
||||
{
|
||||
var term = filter.SearchTerm.Trim();
|
||||
var matches =
|
||||
ev.LeagueId.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
ev.Side1Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
ev.Side2Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
ev.Category.Contains(term, StringComparison.OrdinalIgnoreCase);
|
||||
if (!matches) continue;
|
||||
}
|
||||
|
||||
rows.Add(new EventResultListItem(
|
||||
Id: ev.Id,
|
||||
Sport: ev.Sport,
|
||||
CountryCode: ev.CountryCode,
|
||||
LeagueId: ev.LeagueId,
|
||||
Side1Name: ev.Side1Name,
|
||||
Side2Name: ev.Side2Name,
|
||||
ScheduledAt: ev.ScheduledAt,
|
||||
Side1Score: result.Side1Score,
|
||||
Side2Score: result.Side2Score,
|
||||
WinnerSide: result.WinnerSide,
|
||||
CompletedAt: result.CompletedAt));
|
||||
}
|
||||
|
||||
// Most-recent first.
|
||||
return rows.OrderByDescending(r => r.CompletedAt).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EventResultCandidate>> ListLoadCandidatesAsync(
|
||||
DateRange range,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
||||
var allResults = await _results.ListAsync(ct).ConfigureAwait(false);
|
||||
var withResult = allResults.Select(r => r.EventId.Value).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return events
|
||||
.Where(e => !withResult.Contains(e.Id.Value))
|
||||
.OrderBy(e => e.ScheduledAt)
|
||||
.Select(e => new EventResultCandidate(
|
||||
Id: e.Id,
|
||||
Sport: e.Sport,
|
||||
CountryCode: e.CountryCode,
|
||||
LeagueId: e.LeagueId,
|
||||
Side1Name: e.Side1Name,
|
||||
Side2Name: e.Side2Name,
|
||||
ScheduledAt: e.ScheduledAt))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,13 @@ public static class UiServicesExtensions
|
||||
|
||||
services.AddMudServices();
|
||||
|
||||
services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
// No ResourcesPath: the SharedResource type already lives in the
|
||||
// Marathon.UI.Resources namespace, so its compiled .resources name
|
||||
// is "Marathon.UI.Resources.SharedResource.{culture}.resources" which
|
||||
// matches the type's FullName directly. Setting ResourcesPath="Resources"
|
||||
// here would cause the resolver to look for "...Resources.Resources..."
|
||||
// and silently fall back to displaying the keys.
|
||||
services.AddLocalization();
|
||||
|
||||
// Strongly typed options bound to appsettings.json sections.
|
||||
services.Configure<LocalizationOptions>(configuration.GetSection(LocalizationOptions.SectionName));
|
||||
@@ -51,6 +57,7 @@ public static class UiServicesExtensions
|
||||
// Browsing facades — Scoped so they capture the per-circuit repository scope.
|
||||
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
|
||||
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
|
||||
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
|
||||
|
||||
// Settings writer — file path is host-resolved.
|
||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using AngleSharp.Dom;
|
||||
using Bunit;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.UI.Pages.Results;
|
||||
using Marathon.UI.Tests.Support;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Marathon.UI.Tests.Pages.Results;
|
||||
|
||||
public sealed class ResultsListTests : MarathonTestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Renders_seeded_results()
|
||||
{
|
||||
Browsing.SportCodes.AddRange(new[] { 11, 6 });
|
||||
Results.ResultItems.AddRange(new[]
|
||||
{
|
||||
TestData.ResultListItem(id: "EV-1", side1: "Arsenal", side2: "Chelsea", side1Score: 2, side2Score: 1, winner: Side.Side1),
|
||||
TestData.ResultListItem(id: "EV-2", side1: "Lakers", side2: "Bulls", sport: 6, side1Score: 105, side2Score: 110, winner: Side.Side2),
|
||||
});
|
||||
|
||||
var cut = RenderComponent<ResultsList>();
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var rows = cut.FindAll("[data-test=results-row]");
|
||||
rows.Count.Should().Be(2);
|
||||
});
|
||||
|
||||
cut.Markup.Should().Contain("Arsenal");
|
||||
cut.Markup.Should().Contain("Lakers");
|
||||
var scoreCells = cut.FindAll("[data-test=results-score]").Select(e => e.TextContent.Trim()).ToList();
|
||||
scoreCells.Should().Contain("2:1");
|
||||
scoreCells.Should().Contain("105:110");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_empty_state_when_no_results()
|
||||
{
|
||||
var cut = RenderComponent<ResultsList>();
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
cut.Markup.Should().Contain("Results.Empty");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Winner_filter_narrows_to_selected_side()
|
||||
{
|
||||
Results.ResultItems.AddRange(new[]
|
||||
{
|
||||
TestData.ResultListItem(id: "EV-1", side1: "Arsenal", side2: "Chelsea", winner: Side.Side1),
|
||||
TestData.ResultListItem(id: "EV-2", side1: "Real", side2: "Barca", winner: Side.Side2),
|
||||
TestData.ResultListItem(id: "EV-3", side1: "City", side2: "Spurs", winner: Side.Draw),
|
||||
});
|
||||
|
||||
var cut = RenderComponent<ResultsList>();
|
||||
cut.WaitForAssertion(() => cut.FindAll("[data-test=results-row]").Count.Should().Be(3));
|
||||
|
||||
// Filter by Side2 winner — expect only ev-2
|
||||
cut.Find("[data-test=results-winner-filter]").Change("Side2");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Results.LastResultsFilter!.WinnerSide.Should().Be(Side.Side2);
|
||||
cut.FindAll("[data-test=results-row]").Count.Should().Be(1);
|
||||
cut.Markup.Should().Contain("Real");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_loader_button_navigates_to_loader_route()
|
||||
{
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
|
||||
var cut = RenderComponent<ResultsList>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-open-loader]"));
|
||||
cut.Find("[data-test=results-open-loader]").Click();
|
||||
|
||||
nav.Uri.Should().EndWith("/results/load");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clicking_a_row_navigates_to_event_detail()
|
||||
{
|
||||
Results.ResultItems.Add(TestData.ResultListItem(id: "EV-42"));
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
|
||||
var cut = RenderComponent<ResultsList>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-row]"));
|
||||
|
||||
cut.Find("[data-test=results-row]").Click();
|
||||
|
||||
nav.Uri.Should().Contain("/events/EV-42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Bunit;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Marathon.UI.Pages.Results;
|
||||
using Marathon.UI.Tests.Support;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.UI.Tests.Pages.Results;
|
||||
|
||||
public sealed class ResultsLoaderTests : MarathonTestContext
|
||||
{
|
||||
private readonly IOddsScraper _scraper = Substitute.For<IOddsScraper>();
|
||||
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
|
||||
|
||||
public ResultsLoaderTests()
|
||||
{
|
||||
// The page resolves PullResultsUseCase via IServiceProvider.CreateAsyncScope(),
|
||||
// so the use case must be reachable from the bUnit container. We register a
|
||||
// factory that builds a real use-case wired to the substituted dependencies.
|
||||
Services.AddScoped(_ => _scraper);
|
||||
Services.AddScoped(_ => _eventRepo);
|
||||
Services.AddScoped(_ => _resultRepo);
|
||||
Services.AddScoped(sp => new PullResultsUseCase(
|
||||
sp.GetRequiredService<IOddsScraper>(),
|
||||
sp.GetRequiredService<IEventRepository>(),
|
||||
sp.GetRequiredService<IResultRepository>(),
|
||||
NullLogger<PullResultsUseCase>.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_back_button_and_form()
|
||||
{
|
||||
var cut = RenderComponent<ResultsLoader>();
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
cut.Find("[data-test=results-loader-back]");
|
||||
cut.Find("[data-test=results-loader-mode]");
|
||||
cut.Find("[data-test=results-loader-start]");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selected_mode_shows_candidates()
|
||||
{
|
||||
Results.Candidates.AddRange(new[]
|
||||
{
|
||||
TestData.ResultCandidate(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
|
||||
TestData.ResultCandidate(id: "EV-2", side1: "Lakers", side2: "Bulls", sport: 6),
|
||||
});
|
||||
|
||||
var cut = RenderComponent<ResultsLoader>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-mode]"));
|
||||
|
||||
cut.Find("[data-test=results-loader-mode]").Change("Selected");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var rows = cut.FindAll("[data-test=results-loader-candidate]");
|
||||
rows.Count.Should().Be(2);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_candidates_shows_empty_state_in_selected_mode()
|
||||
{
|
||||
var cut = RenderComponent<ResultsLoader>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-mode]"));
|
||||
|
||||
cut.Find("[data-test=results-loader-mode]").Change("Selected");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
cut.Find("[data-test=results-loader-no-candidates]");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_in_range_load_invokes_scraper_for_every_candidate_and_reports_progress()
|
||||
{
|
||||
// Two candidates returned by the browsing service — but the use case
|
||||
// calls _eventRepo.ListByDateRangeAsync (NULL selection path), not the
|
||||
// browsing service. So we wire the EventRepository directly.
|
||||
var ev1 = TestEvent("EV-1", "Arsenal", "Chelsea");
|
||||
var ev2 = TestEvent("EV-2", "Lakers", "Bulls");
|
||||
_eventRepo.ListByDateRangeAsync(Arg.Any<Marathon.Application.Storage.DateRange>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Event> { ev1, ev2 }.AsReadOnly());
|
||||
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
|
||||
.Returns((EventResult?)null);
|
||||
|
||||
var result1 = new EventResult(ev1.Id, 2, 1, Side.Side1, DateTimeOffset.UtcNow);
|
||||
var result2 = new EventResult(ev2.Id, 99, 105, Side.Side2, DateTimeOffset.UtcNow);
|
||||
_scraper.ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev1.Id), Arg.Any<CancellationToken>())
|
||||
.Returns(result1);
|
||||
_scraper.ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev2.Id), Arg.Any<CancellationToken>())
|
||||
.Returns(result2);
|
||||
|
||||
// The page also queries the browsing service for candidates so the Load
|
||||
// button is enabled.
|
||||
Results.Candidates.AddRange(new[]
|
||||
{
|
||||
TestData.ResultCandidate(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
|
||||
TestData.ResultCandidate(id: "EV-2", side1: "Lakers", side2: "Bulls"),
|
||||
});
|
||||
|
||||
var cut = RenderComponent<ResultsLoader>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-start]"));
|
||||
|
||||
cut.Find("[data-test=results-loader-start]").Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both events scraped → both rows appear in the progress log; the
|
||||
// summary element is rendered (its text uses a localizer key that
|
||||
// the test stub doesn't substitute, so we don't assert on its text).
|
||||
cut.Find("[data-test=results-loader-summary]");
|
||||
cut.FindAll("[data-test=results-loader-log-row]").Count.Should().Be(2);
|
||||
});
|
||||
|
||||
// Both events were inspected via the scraper
|
||||
_scraper.Received(1).ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev1.Id), Arg.Any<CancellationToken>());
|
||||
_scraper.Received(1).ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev2.Id), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selected_mode_load_invokes_scraper_only_for_picked_events()
|
||||
{
|
||||
var ev1 = TestEvent("EV-1", "Arsenal", "Chelsea");
|
||||
var ev2 = TestEvent("EV-2", "Lakers", "Bulls");
|
||||
|
||||
// Use case selection-mode resolves each picked event via _eventRepo.GetAsync.
|
||||
_eventRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>()).Returns(ev1);
|
||||
_eventRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>()).Returns(ev2);
|
||||
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
|
||||
.Returns((EventResult?)null);
|
||||
|
||||
_scraper.ScrapeEventResultAsync(Arg.Any<Event>(), Arg.Any<CancellationToken>())
|
||||
.Returns((EventResult?)null);
|
||||
|
||||
Results.Candidates.AddRange(new[]
|
||||
{
|
||||
TestData.ResultCandidate(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
|
||||
TestData.ResultCandidate(id: "EV-2", side1: "Lakers", side2: "Bulls"),
|
||||
});
|
||||
|
||||
var cut = RenderComponent<ResultsLoader>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-mode]"));
|
||||
|
||||
cut.Find("[data-test=results-loader-mode]").Change("Selected");
|
||||
cut.WaitForAssertion(() => cut.FindAll("[data-test=results-loader-candidate] input[type=checkbox]").Count.Should().Be(2));
|
||||
|
||||
// Pick only EV-1
|
||||
var checkboxes = cut.FindAll("[data-test=results-loader-candidate] input[type=checkbox]");
|
||||
checkboxes[0].Change(true);
|
||||
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-start]"));
|
||||
cut.Find("[data-test=results-loader-start]").Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
cut.Find("[data-test=results-loader-summary]");
|
||||
});
|
||||
|
||||
// Only EV-1 was inspected via use-case
|
||||
_eventRepo.Received(1).GetAsync(ev1.Id, Arg.Any<CancellationToken>());
|
||||
_eventRepo.DidNotReceive().GetAsync(ev2.Id, Arg.Any<CancellationToken>());
|
||||
_scraper.Received(1).ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev1.Id), Arg.Any<CancellationToken>());
|
||||
_scraper.DidNotReceive().ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev2.Id), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Back_button_navigates_to_results_list()
|
||||
{
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
|
||||
var cut = RenderComponent<ResultsLoader>();
|
||||
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-back]"));
|
||||
|
||||
cut.Find("[data-test=results-loader-back]").Click();
|
||||
|
||||
nav.Uri.Should().EndWith("/results");
|
||||
}
|
||||
|
||||
private static Event TestEvent(string id, string side1, string side2)
|
||||
{
|
||||
var moscowOffset = TimeSpan.FromHours(3);
|
||||
var todayMoscow = DateTimeOffset.UtcNow.ToOffset(moscowOffset);
|
||||
var midnightMoscow = new DateTimeOffset(
|
||||
todayMoscow.Year, todayMoscow.Month, todayMoscow.Day,
|
||||
18, 0, 0, moscowOffset);
|
||||
|
||||
return new Event(
|
||||
new EventId(id),
|
||||
new SportCode(11),
|
||||
"ENG",
|
||||
"Premier League",
|
||||
"Group A",
|
||||
midnightMoscow,
|
||||
side1,
|
||||
side2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.UI.Services;
|
||||
|
||||
namespace Marathon.UI.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IResultsBrowsingService"/> for bUnit tests.
|
||||
/// Seed via <see cref="ResultItems"/> / <see cref="Candidates"/>.
|
||||
/// </summary>
|
||||
public sealed class FakeResultsBrowsingService : IResultsBrowsingService
|
||||
{
|
||||
public List<EventResultListItem> ResultItems { get; } = new();
|
||||
public List<EventResultCandidate> Candidates { get; } = new();
|
||||
public ResultsFilter? LastResultsFilter { get; private set; }
|
||||
public DateRange? LastCandidatesRange { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<EventResultListItem>> ListResultsAsync(
|
||||
ResultsFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
LastResultsFilter = filter;
|
||||
IEnumerable<EventResultListItem> q = ResultItems;
|
||||
|
||||
if (filter.SportCodes is { Count: > 0 } sports)
|
||||
q = q.Where(r => sports.Contains(r.Sport.Value));
|
||||
|
||||
if (filter.WinnerSide is { } winner)
|
||||
q = q.Where(r => r.WinnerSide == winner);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
|
||||
{
|
||||
var t = filter.SearchTerm.Trim();
|
||||
q = q.Where(r =>
|
||||
r.LeagueId.Contains(t, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Side1Name.Contains(t, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Side2Name.Contains(t, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EventResultListItem>>(q.ToList());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EventResultCandidate>> ListLoadCandidatesAsync(
|
||||
DateRange range,
|
||||
CancellationToken ct)
|
||||
{
|
||||
LastCandidatesRange = range;
|
||||
return Task.FromResult<IReadOnlyList<EventResultCandidate>>(Candidates.ToList());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public abstract class MarathonTestContext : TestContext
|
||||
protected FakeEventBrowsingService Browsing { get; } = new();
|
||||
protected AnomalyBrowsingState AnomalyState { get; } = new();
|
||||
protected FakeAnomalyBrowsingService AnomalyBrowsing { get; } = new();
|
||||
protected FakeResultsBrowsingService Results { get; } = new();
|
||||
|
||||
protected MarathonTestContext()
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public abstract class MarathonTestContext : TestContext
|
||||
Services.AddSingleton<IEventBrowsingService>(Browsing);
|
||||
Services.AddSingleton(AnomalyState);
|
||||
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
|
||||
Services.AddSingleton<IResultsBrowsingService>(Results);
|
||||
|
||||
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
|
||||
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
|
||||
@@ -44,6 +44,48 @@ internal static class TestData
|
||||
return midnight.AddHours(hour);
|
||||
}
|
||||
|
||||
public static EventResultListItem ResultListItem(
|
||||
string id = "100001",
|
||||
int sport = 11,
|
||||
string country = "ENG",
|
||||
string league = "Premier League",
|
||||
string side1 = "Arsenal",
|
||||
string side2 = "Chelsea",
|
||||
int side1Score = 2,
|
||||
int side2Score = 1,
|
||||
Side winner = Side.Side1,
|
||||
DateTimeOffset? scheduled = null,
|
||||
DateTimeOffset? completed = null)
|
||||
=> new(
|
||||
new EventId(id),
|
||||
new SportCode(sport),
|
||||
country,
|
||||
league,
|
||||
side1,
|
||||
side2,
|
||||
scheduled ?? MoscowToday(20),
|
||||
side1Score,
|
||||
side2Score,
|
||||
winner,
|
||||
completed ?? MoscowToday(22));
|
||||
|
||||
public static EventResultCandidate ResultCandidate(
|
||||
string id = "100001",
|
||||
int sport = 11,
|
||||
string country = "ENG",
|
||||
string league = "Premier League",
|
||||
string side1 = "Arsenal",
|
||||
string side2 = "Chelsea",
|
||||
DateTimeOffset? scheduled = null)
|
||||
=> new(
|
||||
new EventId(id),
|
||||
new SportCode(sport),
|
||||
country,
|
||||
league,
|
||||
side1,
|
||||
side2,
|
||||
scheduled ?? MoscowToday(20));
|
||||
|
||||
public static EventDetail Detail(
|
||||
string id = "100001",
|
||||
params OddsTimelinePoint[] timeline)
|
||||
|
||||
Reference in New Issue
Block a user