feat(ui): event autocomplete + log-bet deep link, steam-move label

- MyBets: add a "Find event" MudAutocomplete over upcoming events (loaded once,
  filtered client-side) that fills the Event ID; the manual ID field stays as a
  fallback. Backed by IBetJournalService.GetUpcomingEventOptionsAsync.
- Add a "Log bet" CTA on the anomaly detail page that deep-links to
  /my-bets?eventId=<code>; the journal prefills the Event ID from the query.
- Render the new SteamMove anomaly kind with a localized label in the card and
  detail KindLabel switches (was falling through to the raw enum name).
- Localization (en+ru) for all new strings.
This commit is contained in:
2026-05-28 23:08:56 +03:00
parent 2b1025cae3
commit d9d92ea8fd
7 changed files with 103 additions and 0 deletions
@@ -212,6 +212,7 @@
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
_ => kind.ToString(),
};
@@ -54,6 +54,13 @@
data-test="link-back-to-event">
@L["Anomaly.Detail.LinkBackToEvent"]
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.Receipt"
OnClick="@(() => Nav.NavigateTo($"/my-bets?eventId={Uri.EscapeDataString(_detail.Item.EventId.Value)}"))"
Class="m-detail-header__export"
data-test="log-bet">
@L["Action.LogBet"]
</MudButton>
</aside>
</header>
@@ -99,6 +106,7 @@
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
_ => kind.ToString(),
};
@@ -114,6 +114,20 @@
<article class="m-card m-card--accented m-journal__form-card">
<div class="m-journal__form-grid">
<div class="m-journal__form-field m-journal__form-field--wide">
<label class="m-journal__form-label">@L["Journal.Field.FindEvent"]</label>
<MudAutocomplete T="EventOption"
SearchFunc="SearchEventsAsync"
ToStringFunc="@(o => o is null ? string.Empty : o.Label)"
ValueChanged="OnEventSelected"
Variant="Variant.Outlined"
Clearable="true"
ResetValueOnEmptyText="true"
Placeholder="@L["Journal.Field.FindEvent.Placeholder"]"
data-test="journal-find-event" />
<span class="m-journal__form-hint">@L["Journal.Field.FindEvent.Hint"]</span>
</div>
<div class="m-journal__form-field m-journal__form-field--wide">
<label class="m-journal__form-label" for="journal-event-id">@L["Journal.Field.EventId"]</label>
<MudTextField id="journal-event-id"
@@ -718,9 +732,48 @@
}
private CancellationTokenSource? _loadCts;
private IReadOnlyList<EventOption> _eventOptions = Array.Empty<EventOption>();
/// <summary>Optional event code supplied by a "Log bet" deep link (e.g. from an anomaly).</summary>
[Parameter, SupplyParameterFromQuery(Name = "eventId")]
public string? PrefillEventId { get; set; }
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrWhiteSpace(PrefillEventId))
_form.EventId = PrefillEventId.Trim();
await LoadAsync();
await LoadEventOptionsAsync();
}
private async Task LoadEventOptionsAsync()
{
try
{
var options = await Service.GetUpcomingEventOptionsAsync(_loadCts?.Token ?? CancellationToken.None);
_eventOptions = options ?? Array.Empty<EventOption>();
}
catch (Exception ex)
{
// The autocomplete is a convenience; failing to populate it must not break the page.
Logger.LogWarning(ex, "Journal: failed to load event options for the autocomplete.");
_eventOptions = Array.Empty<EventOption>();
}
}
private Task<IEnumerable<EventOption>> SearchEventsAsync(string? value, CancellationToken token)
{
IEnumerable<EventOption> matches = string.IsNullOrWhiteSpace(value)
? _eventOptions
: _eventOptions.Where(o => o.Label.Contains(value, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(matches.Take(20));
}
private void OnEventSelected(EventOption? option)
{
if (option is not null)
_form.EventId = option.Id;
}
private async Task LoadAsync()
@@ -163,6 +163,7 @@
<data name="Anomaly.Live"><value>Anomaly</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 7 — Anomaly feed UI -->
@@ -190,6 +191,7 @@
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
<data name="Action.LogBet"><value>Log bet</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
@@ -387,6 +389,9 @@
<data name="Journal.Action.Cancel"><value>Cancel</value></data>
<data name="Journal.Field.EventId"><value>Event ID</value></data>
<data name="Journal.Field.EventId.Hint"><value>Numeric ID from the event detail URL.</value></data>
<data name="Journal.Field.FindEvent"><value>Find event</value></data>
<data name="Journal.Field.FindEvent.Placeholder"><value>Search by team name…</value></data>
<data name="Journal.Field.FindEvent.Hint"><value>Pick an upcoming event to fill the ID, or type it below.</value></data>
<data name="Journal.Field.Type"><value>Bet type</value></data>
<data name="Journal.Field.Side"><value>Side</value></data>
<data name="Journal.Field.Value"><value>Threshold</value></data>
@@ -176,6 +176,7 @@
<!-- Anomaly (Phase 7 placeholders) -->
<data name="Anomaly.Live"><value>Аномалия</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 7 — Лента аномалий -->
@@ -203,6 +204,7 @@
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
<data name="Action.LogBet"><value>Записать ставку</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
@@ -400,6 +402,9 @@
<data name="Journal.Action.Cancel"><value>Отмена</value></data>
<data name="Journal.Field.EventId"><value>ID события</value></data>
<data name="Journal.Field.EventId.Hint"><value>Числовой ID из URL детальной страницы.</value></data>
<data name="Journal.Field.FindEvent"><value>Найти событие</value></data>
<data name="Journal.Field.FindEvent.Placeholder"><value>Поиск по названию команды…</value></data>
<data name="Journal.Field.FindEvent.Hint"><value>Выберите предстоящее событие, чтобы подставить ID, или введите его ниже.</value></data>
<data name="Journal.Field.Type"><value>Тип ставки</value></data>
<data name="Journal.Field.Side"><value>Сторона</value></data>
<data name="Journal.Field.Value"><value>Порог</value></data>
@@ -1,4 +1,6 @@
using System.Globalization;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
@@ -95,4 +97,21 @@ public sealed class BetJournalService : IBetJournalService
public Task<int> ResolvePendingAsync(CancellationToken ct) =>
_resolve.ExecuteAsync(ct);
public async Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct)
{
// Generous betting window: recently started through a month out. Loaded once
// by the page; the autocomplete filters this list in memory per keystroke.
var now = MoscowTime.Now;
var range = new DateRange(now.AddDays(-7), now.AddDays(30));
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
return events
.OrderBy(e => e.ScheduledAt)
.Select(e => new EventOption(
e.Id.Value,
string.Concat(e.Title, " · ", e.ScheduledAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)),
e.ScheduledAt))
.ToList();
}
}
@@ -26,4 +26,16 @@ public interface IBetJournalService
/// Returns the count graded in this pass.
/// </summary>
Task<int> ResolvePendingAsync(CancellationToken ct);
/// <summary>
/// Upcoming (and recently-started) events for the bet-entry autocomplete,
/// ordered by kickoff. Loaded once by the page; filtered client-side per keystroke.
/// </summary>
Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct);
}
/// <summary>A selectable event for the bet-journal "find event" autocomplete.</summary>
/// <param name="Id">The bookmaker event code (what the form's Event ID expects).</param>
/// <param name="Label">Display text: "Home vs Away · yyyy-MM-dd HH:mm".</param>
/// <param name="ScheduledAt">Kickoff, for ordering.</param>
public sealed record EventOption(string Id, string Label, DateTimeOffset ScheduledAt);