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
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
_ => kind.ToString(),
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
data-test="link-back-to-event">
|
data-test="link-back-to-event">
|
||||||
@L["Anomaly.Detail.LinkBackToEvent"]
|
@L["Anomaly.Detail.LinkBackToEvent"]
|
||||||
</MudButton>
|
</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>
|
</aside>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -99,6 +106,7 @@
|
|||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
_ => kind.ToString(),
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,20 @@
|
|||||||
|
|
||||||
<article class="m-card m-card--accented m-journal__form-card">
|
<article class="m-card m-card--accented m-journal__form-card">
|
||||||
<div class="m-journal__form-grid">
|
<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">
|
<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>
|
<label class="m-journal__form-label" for="journal-event-id">@L["Journal.Field.EventId"]</label>
|
||||||
<MudTextField id="journal-event-id"
|
<MudTextField id="journal-event-id"
|
||||||
@@ -718,9 +732,48 @@
|
|||||||
}
|
}
|
||||||
private CancellationTokenSource? _loadCts;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(PrefillEventId))
|
||||||
|
_form.EventId = PrefillEventId.Trim();
|
||||||
|
|
||||||
await LoadAsync();
|
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()
|
private async Task LoadAsync()
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
|
|
||||||
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</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>
|
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Anomaly feed UI -->
|
<!-- Phase 7 — Anomaly feed UI -->
|
||||||
@@ -190,6 +191,7 @@
|
|||||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
|
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
|
||||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
|
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
|
||||||
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</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.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.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>
|
<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.Action.Cancel"><value>Cancel</value></data>
|
||||||
<data name="Journal.Field.EventId"><value>Event ID</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.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.Type"><value>Bet type</value></data>
|
||||||
<data name="Journal.Field.Side"><value>Side</value></data>
|
<data name="Journal.Field.Side"><value>Side</value></data>
|
||||||
<data name="Journal.Field.Value"><value>Threshold</value></data>
|
<data name="Journal.Field.Value"><value>Threshold</value></data>
|
||||||
|
|||||||
@@ -176,6 +176,7 @@
|
|||||||
<!-- Anomaly (Phase 7 placeholders) -->
|
<!-- Anomaly (Phase 7 placeholders) -->
|
||||||
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFlip"><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>
|
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Лента аномалий -->
|
<!-- Phase 7 — Лента аномалий -->
|
||||||
@@ -203,6 +204,7 @@
|
|||||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
|
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
|
||||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
|
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
|
||||||
<data name="Anomaly.Detail.LinkBackToEvent"><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.BackToFeed"><value>К ленте</value></data>
|
||||||
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
|
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
|
||||||
<data name="Anomaly.Empty.NoneInRange"><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.Action.Cancel"><value>Отмена</value></data>
|
||||||
<data name="Journal.Field.EventId"><value>ID события</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.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.Type"><value>Тип ставки</value></data>
|
||||||
<data name="Journal.Field.Side"><value>Сторона</value></data>
|
<data name="Journal.Field.Side"><value>Сторона</value></data>
|
||||||
<data name="Journal.Field.Value"><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.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
using Marathon.Application.UseCases;
|
using Marathon.Application.UseCases;
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
@@ -95,4 +97,21 @@ public sealed class BetJournalService : IBetJournalService
|
|||||||
|
|
||||||
public Task<int> ResolvePendingAsync(CancellationToken ct) =>
|
public Task<int> ResolvePendingAsync(CancellationToken ct) =>
|
||||||
_resolve.ExecuteAsync(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.
|
/// Returns the count graded in this pass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<int> ResolvePendingAsync(CancellationToken ct);
|
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