From d9d92ea8fdb41f76c1219ce37c2c0e853e67666e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 28 May 2026 23:08:56 +0300 Subject: [PATCH] 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=; 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. --- src/Marathon.UI/Components/AnomalyCard.razor | 1 + src/Marathon.UI/Pages/Anomalies/Detail.razor | 8 +++ src/Marathon.UI/Pages/MyBets/Journal.razor | 53 +++++++++++++++++++ .../Resources/SharedResource.en.resx | 5 ++ .../Resources/SharedResource.ru.resx | 5 ++ src/Marathon.UI/Services/BetJournalService.cs | 19 +++++++ .../Services/IBetJournalService.cs | 12 +++++ 7 files changed, 103 insertions(+) diff --git a/src/Marathon.UI/Components/AnomalyCard.razor b/src/Marathon.UI/Components/AnomalyCard.razor index 91246b0..81a6128 100644 --- a/src/Marathon.UI/Components/AnomalyCard.razor +++ b/src/Marathon.UI/Components/AnomalyCard.razor @@ -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(), }; diff --git a/src/Marathon.UI/Pages/Anomalies/Detail.razor b/src/Marathon.UI/Pages/Anomalies/Detail.razor index 5741aec..1597bb0 100644 --- a/src/Marathon.UI/Pages/Anomalies/Detail.razor +++ b/src/Marathon.UI/Pages/Anomalies/Detail.razor @@ -54,6 +54,13 @@ data-test="link-back-to-event"> @L["Anomaly.Detail.LinkBackToEvent"] + + @L["Action.LogBet"] + @@ -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(), }; diff --git a/src/Marathon.UI/Pages/MyBets/Journal.razor b/src/Marathon.UI/Pages/MyBets/Journal.razor index af6eac6..e56e6d7 100644 --- a/src/Marathon.UI/Pages/MyBets/Journal.razor +++ b/src/Marathon.UI/Pages/MyBets/Journal.razor @@ -114,6 +114,20 @@
+
+ + + @L["Journal.Field.FindEvent.Hint"] +
+
_eventOptions = Array.Empty(); + + /// Optional event code supplied by a "Log bet" deep link (e.g. from an anomaly). + [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(); + } + 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(); + } + } + + private Task> SearchEventsAsync(string? value, CancellationToken token) + { + IEnumerable 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() diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 5a70b3e..0ea8ce4 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -163,6 +163,7 @@ Anomaly Suspension flip + Steam move Confidence @@ -190,6 +191,7 @@ Favourite swap Evidence timeline Open event + Log bet Back to feed Anomaly not found — it may have been pruned. No anomalies match the current filters. Loosen the severity threshold or widen the date range. @@ -387,6 +389,9 @@ Cancel Event ID Numeric ID from the event detail URL. + Find event + Search by team name… + Pick an upcoming event to fill the ID, or type it below. Bet type Side Threshold diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 6d7763d..4663c71 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -176,6 +176,7 @@ Аномалия Разворот после заморозки + Движение линии Уверенность @@ -203,6 +204,7 @@ Смена фаворита Хроника свидетельств Открыть событие + Записать ставку К ленте Аномалия не найдена — возможно, она была удалена. Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат. @@ -400,6 +402,9 @@ Отмена ID события Числовой ID из URL детальной страницы. + Найти событие + Поиск по названию команды… + Выберите предстоящее событие, чтобы подставить ID, или введите его ниже. Тип ставки Сторона Порог diff --git a/src/Marathon.UI/Services/BetJournalService.cs b/src/Marathon.UI/Services/BetJournalService.cs index 267f788..3c691b5 100644 --- a/src/Marathon.UI/Services/BetJournalService.cs +++ b/src/Marathon.UI/Services/BetJournalService.cs @@ -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 ResolvePendingAsync(CancellationToken ct) => _resolve.ExecuteAsync(ct); + + public async Task> 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(); + } } diff --git a/src/Marathon.UI/Services/IBetJournalService.cs b/src/Marathon.UI/Services/IBetJournalService.cs index 4e1c5b9..b77d9c0 100644 --- a/src/Marathon.UI/Services/IBetJournalService.cs +++ b/src/Marathon.UI/Services/IBetJournalService.cs @@ -26,4 +26,16 @@ public interface IBetJournalService /// Returns the count graded in this pass. /// Task ResolvePendingAsync(CancellationToken ct); + + /// + /// Upcoming (and recently-started) events for the bet-entry autocomplete, + /// ordered by kickoff. Loaded once by the page; filtered client-side per keystroke. + /// + Task> GetUpcomingEventOptionsAsync(CancellationToken ct); } + +/// A selectable event for the bet-journal "find event" autocomplete. +/// The bookmaker event code (what the form's Event ID expects). +/// Display text: "Home vs Away · yyyy-MM-dd HH:mm". +/// Kickoff, for ordering. +public sealed record EventOption(string Id, string Label, DateTimeOffset ScheduledAt);