Files
maraphon-app/src/Marathon.UI/Services/BetJournalService.cs
T
alexei.dolgolyov d9d92ea8fd 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.
2026-05-28 23:08:56 +03:00

118 lines
4.5 KiB
C#

using System.Globalization;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IBetJournalService"/>. Composes the
/// four bet-journal use cases and joins event titles for the table rows.
/// </summary>
public sealed class BetJournalService : IBetJournalService
{
private readonly BuildBetJournalReportUseCase _build;
private readonly RecordPlacedBetUseCase _record;
private readonly ResolvePendingBetsUseCase _resolve;
private readonly DeletePlacedBetUseCase _delete;
private readonly IEventRepository _events;
public BetJournalService(
BuildBetJournalReportUseCase build,
RecordPlacedBetUseCase record,
ResolvePendingBetsUseCase resolve,
DeletePlacedBetUseCase delete,
IEventRepository events)
{
_build = build ?? throw new ArgumentNullException(nameof(build));
_record = record ?? throw new ArgumentNullException(nameof(record));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
public async Task<BetJournalVm> GetReportAsync(CancellationToken ct)
{
var report = await _build.ExecuteAsync(ct).ConfigureAwait(false);
if (report.Bets.Count == 0)
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
// Resolve event titles in one batched query — distinct ids only (was N+1).
// Missing events (pruned by snapshot retention) fall back to the raw id.
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(distinctIds.Count);
foreach (var id in distinctIds)
{
titles[id] = events.TryGetValue(id, out var ev)
? ev.Title
: id.Value;
}
var rows = report.Bets
.Select(r => new BetJournalRowVm(
Id: r.Bet.Id,
EventTitle: titles.TryGetValue(r.Bet.EventId, out var t) ? t : r.Bet.EventId.Value,
Bet: r.Bet,
ClvProbabilityDelta: r.ClvProbabilityDelta))
.ToList();
return new BetJournalVm(report.Stats, rows);
}
public async Task<Guid> AddAsync(AddBetForm form, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(form);
if (!form.IsValid(out var error))
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
var selection = new Bet(
scope: MatchScope.Instance,
type: form.Type,
side: form.Side,
value: form.Value is { } v ? new OddsValue(v) : null,
rate: new OddsRate(form.Rate));
var bet = new PlacedBet(
Id: Guid.NewGuid(),
EventId: new DomainEventId(form.EventId.Trim()),
Selection: selection,
Stake: form.Stake,
PlacedAt: MoscowTime.Now,
Outcome: BetOutcome.Pending,
Notes: form.Notes);
var stored = await _record.ExecuteAsync(bet, ct).ConfigureAwait(false);
return stored.Id;
}
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
_delete.ExecuteAsync(betId, ct);
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();
}
}