@* 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 @using Marathon.Domain.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.FindEvent.Hint"]
@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"]
}
@L["Journal.Kelly.Bankroll"]
@L["Journal.Kelly.Probability"]
@if (KellySuggestion is { } suggestion) { @if (suggestion > 0m) {
@string.Format(CultureInfo.CurrentCulture, L["Journal.Kelly.Suggestion"].Value, suggestion)
} else { @L["Journal.Kelly.NoEdge"] } } else { @L["Journal.Kelly.Hint"] }
@if (!string.IsNullOrEmpty(_formError)) {

@_formError

} @if (_editingId is not null) {

@L["Journal.Editing"]

}
@if (_editingId is not null) { }

@* ---------- 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 Guid? _editingId; private AddBetForm _form = new(); // Kelly stake-helper state — page-local (not persisted with the bet). Bankroll // intentionally survives form resets so it carries across successive entries. private decimal? _kellyBankroll; private decimal? _kellyProbabilityPercent; /// /// Quarter-Kelly suggested stake from the entered bankroll + win probability and /// the form's current rate. Null when inputs are incomplete/invalid; 0 when the /// price carries no positive edge. /// private decimal? KellySuggestion { get { if (_kellyBankroll is not { } bankroll || bankroll <= 0m) return null; if (_kellyProbabilityPercent is not { } pct || pct <= 0m || pct >= 100m) return null; if (_form.Rate <= 1m) return null; return KellyCalculator.SuggestStake(pct / 100m, _form.Rate, bankroll, KellyCalculator.DefaultFraction); } } private void ApplyKellyStake() { if (KellySuggestion is { } s && s > 0m) _form.Stake = s; } private CancellationTokenSource? _loadCts; private IReadOnlyList _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() { _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 { if (_editingId is { } editId) { await Service.UpdateAsync(editId, _form, ct); Snackbar.Add(L["Journal.Edited"].Value, Severity.Success); } else { await Service.AddAsync(_form, ct); Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success); } _editingId = null; _form = new AddBetForm(); _formError = null; 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 BeginEdit(BetJournalRowVm row) { var b = row.Bet; _editingId = row.Id; _pendingDeleteId = null; _form = new AddBetForm { EventId = b.EventId.Value, Type = b.Selection.Type, Side = b.Selection.Side, Value = b.Selection.Value?.Value, Rate = b.Selection.Rate.Value, Stake = b.Stake, Notes = b.Notes, }; _formError = null; } private void CancelEdit() { _editingId = null; _form = new AddBetForm(); _formError = 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(); } }