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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user