@* Journal — the user's personal bet tracker. Loads a precomputed BetJournalVm and exposes it as the editorial-quant ledger that mirrors Insights / AnomalyFeed: a hero header in the accent tone (positive product surface, not anomaly-red), a KPI strip, a compact record-a-bet form, and a list of every wager with P&L, CLV, and outcome. *@ @page "/my-bets" @using Marathon.Application.Betting @implements IDisposable @inject IStringLocalizer L @inject IBetJournalService Service @inject ISnackbar Snackbar @inject ILogger Logger @L["App.Title"] · @L["Nav.MyBets"]
@L["Journal.Kicker"]

@L["Journal.Title"]

@L["Journal.Lede"]

@if (_loading && _vm is null) {
@L["Common.Loading"]
} else if (_errored && _vm is null) {
@L["Common.Empty"]

@L["Journal.Empty.None"]

} else if (_vm is { } vm) { @* ---------- KPI strip ---------- *@
@L["Journal.Stat.Roi"] @FormatSignedPercent(vm.Stats.RoiPercent) @L["Journal.Stat.Roi.Hint"]
@L["Journal.Stat.StrikeRate"] @FormatPercent(vm.Stats.StrikeRatePercent) @L["Journal.Stat.StrikeRate.Hint"]
@L["Journal.Stat.AvgClv"] @FormatClvPoints(vm.Stats.AverageClvProbabilityDelta) @L["Journal.Stat.AvgClv.Hint"]
@L["Journal.Stat.NetProfit"] @FormatSignedDecimal(vm.Stats.NetProfit, vm.Stats.ResolvedCount) @L["Journal.Stat.NetProfit.Hint"]
@L["Journal.Stat.TotalBets"] @vm.Stats.TotalBets @L["Journal.Stat.Pending"] @vm.Stats.PendingCount @L["Journal.Stat.Won"] @vm.Stats.WonCount @L["Journal.Stat.Lost"] @vm.Stats.LostCount @L["Journal.Stat.Void"] @vm.Stats.VoidCount

@* ---------- Record-a-bet form ---------- *@
@L["Journal.Section.Add"]
@L["Journal.Field.EventId.Hint"]
@foreach (var betType in _betTypes) { @BetTypeLabel(betType) }
@foreach (var side in SidesFor(_form.Type)) { @SideLabel(side) }
@if (_form.Type is BetType.WinFora or BetType.Total) {
@L["Journal.Field.Value.Hint"]
}
@if (!string.IsNullOrEmpty(_formError)) {

@_formError

}

@* ---------- Bets list ---------- *@
@L["Journal.Section.List"] @vm.Bets.Count
@if (vm.Bets.Count == 0) {
@L["Common.Empty"]

@L["Journal.Empty.None"]

} else {
@foreach (var bet in vm.Bets) { var row = bet; }
@L["Journal.Column.PlacedAt"] @L["Journal.Column.Match"] @L["Journal.Column.Selection"] @L["Journal.Column.Stake"] @L["Journal.Column.Rate"] @L["Journal.Column.Profit"] @L["Journal.Column.Clv"] @L["Journal.Column.Outcome"]
@row.Bet.PlacedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) @row.EventTitle
@SelectionLabel(row.Bet.Selection)
@if (!string.IsNullOrWhiteSpace(row.Bet.Notes)) {
@row.Bet.Notes
}
@row.Bet.Stake.ToString("0.00", CultureInfo.InvariantCulture) @row.Bet.Selection.Rate.Value.ToString("0.00", CultureInfo.InvariantCulture) @FormatProfit(row.Bet.NetProfit) @FormatClvPoints(row.ClvProbabilityDelta) @OutcomeLabel(row.Bet.Outcome) @if (_pendingDeleteId == row.Id) { @L["Journal.Confirm.Delete"] } else { }
}
}
@code { private static readonly BetType[] _betTypes = { BetType.Win, BetType.Draw, BetType.WinFora, BetType.Total }; private BetJournalVm? _vm; private bool _loading = true; private bool _errored; private bool _submitting; private bool _resolving; private string? _formError; private Guid? _pendingDeleteId; private AddBetForm _form = new(); private CancellationTokenSource? _loadCts; protected override async Task OnInitializedAsync() { await LoadAsync(); } private async Task LoadAsync() { _loadCts?.Cancel(); _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; _loading = true; _errored = false; StateHasChanged(); try { var report = await Service.GetReportAsync(ct); if (ct.IsCancellationRequested) return; _vm = report; _pendingDeleteId = null; } catch (OperationCanceledException) { /* superseded */ } catch (Exception ex) { Logger.LogError(ex, "Failed to load bet journal report."); _errored = true; _vm = null; } finally { _loading = false; StateHasChanged(); } } private async Task ResolvePendingAsync() { if (_resolving) return; _resolving = true; StateHasChanged(); var ct = _loadCts?.Token ?? CancellationToken.None; try { var graded = await Service.ResolvePendingAsync(ct); var msg = graded == 0 ? L["Journal.Resolve.None"].Value : string.Format(CultureInfo.CurrentCulture, L["Journal.Resolve.Done"].Value, graded); Snackbar.Add(msg, graded == 0 ? Severity.Info : Severity.Success); } catch (OperationCanceledException) { /* superseded */ } catch (Exception ex) { Logger.LogError(ex, "Failed to resolve pending bets."); Snackbar.Add(L["Journal.Error.Generic"].Value, Severity.Error); } finally { _resolving = false; await LoadAsync(); } } private async Task SubmitAsync() { if (_submitting) return; _formError = null; if (!_form.IsValid(out var err)) { _formError = err; StateHasChanged(); return; } _submitting = true; StateHasChanged(); var ct = _loadCts?.Token ?? CancellationToken.None; try { await Service.AddAsync(_form, ct); _form = new AddBetForm(); _formError = null; Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success); await LoadAsync(); } catch (ArgumentException ex) { _formError = ex.Message; } catch (InvalidOperationException ex) { _formError = ex.Message; } catch (Exception ex) { Logger.LogError(ex, "Failed to record bet."); _formError = L["Journal.Error.Generic"].Value; } finally { _submitting = false; StateHasChanged(); } } private void OnTypeChanged(BetType next) { _form.Type = next; var valid = SidesFor(next); if (!valid.Contains(_form.Side)) { _form.Side = valid[0]; } if (next is not (BetType.WinFora or BetType.Total)) { _form.Value = null; } } private void RequestDelete(Guid id) { _pendingDeleteId = id; } private void CancelDelete() { _pendingDeleteId = null; } private async Task ConfirmDeleteAsync(Guid id) { var ct = _loadCts?.Token ?? CancellationToken.None; try { await Service.DeleteAsync(id, ct); } catch (OperationCanceledException) { /* superseded */ } catch (Exception ex) { Logger.LogError(ex, "Failed to delete bet {BetId}.", id); Snackbar.Add(L["Journal.Error.Generic"].Value, Severity.Error); } finally { _pendingDeleteId = null; await LoadAsync(); } } // ---- Formatting / labels ------------------------------------------------ private static IReadOnlyList SidesFor(BetType type) => type switch { BetType.Win => new[] { Side.Side1, Side.Side2 }, BetType.WinFora => new[] { Side.Side1, Side.Side2 }, BetType.Draw => new[] { Side.Draw }, BetType.Total => new[] { Side.Less, Side.More }, _ => new[] { Side.Side1 }, }; private string BetTypeLabel(BetType type) => type switch { BetType.Win => L["Journal.BetType.Win"], BetType.Draw => L["Journal.BetType.Draw"], BetType.WinFora => L["Journal.BetType.WinFora"], BetType.Total => L["Journal.BetType.Total"], _ => type.ToString(), }; private string SideLabel(Side side) => side switch { Side.Side1 => L["Journal.Side.Side1"], Side.Side2 => L["Journal.Side.Side2"], Side.Draw => L["Journal.Side.Draw"], Side.Less => L["Journal.Side.Less"], Side.More => L["Journal.Side.More"], _ => side.ToString(), }; private string OutcomeLabel(BetOutcome o) => o switch { BetOutcome.Won => L["Journal.Outcome.Won"], BetOutcome.Lost => L["Journal.Outcome.Lost"], BetOutcome.Void => L["Journal.Outcome.Void"], BetOutcome.Pending => L["Journal.Outcome.Pending"], _ => L["Journal.Outcome.Pending"], }; private static string OutcomeCss(BetOutcome o) => o switch { BetOutcome.Won => "won", BetOutcome.Lost => "lost", BetOutcome.Void => "void", BetOutcome.Pending => "pending", _ => "pending", }; private string SelectionLabel(Bet selection) { var typeText = BetTypeLabel(selection.Type); var sideText = SideLabel(selection.Side); var rate = selection.Rate.Value.ToString("0.00", CultureInfo.InvariantCulture); if (selection.Value is { } v) { var threshold = v.Value.ToString("0.##", CultureInfo.InvariantCulture); return typeText + " " + sideText + " " + threshold + " @ " + rate; } return typeText + " " + sideText + " @ " + rate; } private static string FormatSignedPercent(decimal? value) { if (value is null) return "—"; var v = value.Value; var sign = v > 0m ? "+" : (v < 0m ? "-" : ""); var abs = Math.Abs(v); return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + "%"; } private static string FormatPercent(decimal? value) { if (value is null) return "—"; // Show one decimal so strike-rate 66.67% does not collapse to 67% — // the user wants to see "50.5%" rather than be lied to. return value.Value.ToString("0.0", CultureInfo.InvariantCulture) + "%"; } private static string FormatClvPoints(decimal? probabilityDelta) { if (probabilityDelta is null) return "—"; var pts = probabilityDelta.Value * 100m; var sign = pts > 0m ? "+" : (pts < 0m ? "-" : ""); var abs = Math.Abs(pts); return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + " pp"; } private static string FormatSignedDecimal(decimal value, int resolvedCount) { if (resolvedCount == 0) return "—"; var sign = value > 0m ? "+" : (value < 0m ? "-" : ""); var abs = Math.Abs(value); return sign + abs.ToString("0.00", CultureInfo.InvariantCulture); } private static string FormatProfit(decimal? value) { if (value is null) return "—"; var v = value.Value; var sign = v > 0m ? "+" : (v < 0m ? "-" : ""); var abs = Math.Abs(v); return sign + abs.ToString("0.00", CultureInfo.InvariantCulture); } private static string SignedTone(decimal? value) => value switch { null => "neutral", > 0m => "positive", < 0m => "negative", _ => "neutral", }; private static string ProfitTone(decimal? value) => value switch { null => "neutral", > 0m => "positive", < 0m => "negative", _ => "neutral", }; private static string ClvTone(decimal? value) => value switch { null => "neutral", > 0m => "positive", < 0m => "negative", _ => "neutral", }; public void Dispose() { _loadCts?.Cancel(); _loadCts?.Dispose(); } }