Files
maraphon-app/src/Marathon.UI/Pages/MyBets/Journal.razor
T
alexei.dolgolyov 1092e2a2c5 feat(journal): edit a placed bet
Adds an edit flow to the bet journal — an Edit button per row repurposes the inline
entry form (edit mode), saving via a new UpdatePlacedBetUseCase that preserves the
original placed-at and re-grades the outcome against the result (so a changed
selection/event re-settles). A cancel affordance + an editing banner make the mode
obvious; the submit button switches to "Save changes".

- UpdatePlacedBetUseCase (loads existing for PlacedAt, validates the event, re-grades)
  + IBetJournalService.UpdateAsync + service impl; Journal page edit-mode state +
  per-row Edit button; en/ru resx.
- 3 tests: unknown-bet, unknown-event, and preserve-PlacedAt + re-grade.
2026-05-29 13:38:38 +03:00

1115 lines
45 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@*
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<SharedResource> L
@inject IBetJournalService Service
@inject ISnackbar Snackbar
@inject ILogger<Journal> Logger
<PageTitle>@L["App.Title"] · @L["Nav.MyBets"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1 m-journal__header" data-test="journal-header">
<div class="m-journal__header-text">
<span class="m-kicker">@L["Journal.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Journal.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Journal.Lede"]</p>
</div>
<div class="m-journal__header-actions">
<button type="button"
class="m-chip m-journal__chip"
@onclick="ResolvePendingAsync"
disabled="@(_loading || _resolving)"
data-test="journal-resolve">
<span class="m-journal__chip-glyph @(_resolving ? "is-spinning" : null)" aria-hidden="true">✓</span>
<span>@L["Journal.Action.Resolve"]</span>
</button>
<button type="button"
class="m-chip m-journal__chip"
@onclick="LoadAsync"
disabled="@_loading"
data-test="journal-refresh">
<span class="m-journal__chip-glyph @(_loading ? "is-spinning" : null)" aria-hidden="true">↻</span>
<span>@L["Journal.Action.Refresh"]</span>
</button>
</div>
</header>
@if (_loading && _vm is null)
{
<div class="m-list-empty m-rise m-rise-2" data-test="journal-loading">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_errored && _vm is null)
{
<div class="m-list-empty m-rise m-rise-2" data-test="journal-error">
<span class="m-kicker" style="border-color: var(--m-c-anomaly); color: var(--m-c-anomaly);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
@L["Journal.Empty.None"]
</p>
</div>
}
else if (_vm is { } vm)
{
@* ---------- KPI strip ---------- *@
<div class="m-journal__kpis m-rise m-rise-2" data-test="journal-kpis">
<article class="m-journal__kpi m-journal__kpi--@SignedTone(vm.Stats.RoiPercent)" data-test="journal-kpi-roi">
<span class="m-journal__kpi-label">@L["Journal.Stat.Roi"]</span>
<span class="m-journal__kpi-value">@FormatSignedPercent(vm.Stats.RoiPercent)</span>
<span class="m-journal__kpi-hint">@L["Journal.Stat.Roi.Hint"]</span>
</article>
<article class="m-journal__kpi" data-test="journal-kpi-strike">
<span class="m-journal__kpi-label">@L["Journal.Stat.StrikeRate"]</span>
<span class="m-journal__kpi-value">@FormatPercent(vm.Stats.StrikeRatePercent)</span>
<span class="m-journal__kpi-hint">@L["Journal.Stat.StrikeRate.Hint"]</span>
</article>
<article class="m-journal__kpi m-journal__kpi--@ClvTone(vm.Stats.AverageClvProbabilityDelta)" data-test="journal-kpi-clv">
<span class="m-journal__kpi-label">@L["Journal.Stat.AvgClv"]</span>
<span class="m-journal__kpi-value">@FormatClvPoints(vm.Stats.AverageClvProbabilityDelta)</span>
<span class="m-journal__kpi-hint">@L["Journal.Stat.AvgClv.Hint"]</span>
</article>
<article class="m-journal__kpi m-journal__kpi--@SignedTone(vm.Stats.NetProfit)" data-test="journal-kpi-profit">
<span class="m-journal__kpi-label">@L["Journal.Stat.NetProfit"]</span>
<span class="m-journal__kpi-value">@FormatSignedDecimal(vm.Stats.NetProfit, vm.Stats.ResolvedCount)</span>
<span class="m-journal__kpi-hint">@L["Journal.Stat.NetProfit.Hint"]</span>
</article>
</div>
<div class="m-journal__counts m-rise m-rise-2 m-mono" data-test="journal-counts">
<span><span class="m-journal__counts-label">@L["Journal.Stat.TotalBets"]</span> <strong>@vm.Stats.TotalBets</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-journal__counts-label">@L["Journal.Stat.Pending"]</span> <strong>@vm.Stats.PendingCount</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-journal__counts-label">@L["Journal.Stat.Won"]</span> <strong style="color: var(--m-c-positive);">@vm.Stats.WonCount</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-journal__counts-label">@L["Journal.Stat.Lost"]</span> <strong style="color: var(--m-c-anomaly);">@vm.Stats.LostCount</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-journal__counts-label">@L["Journal.Stat.Void"]</span> <strong>@vm.Stats.VoidCount</strong></span>
</div>
<hr class="m-rule--double" />
@* ---------- Record-a-bet form ---------- *@
<section class="m-journal__section m-rise m-rise-3" data-test="journal-add">
<header class="m-journal__section-head">
<span class="m-kicker">@L["Journal.Section.Add"]</span>
</header>
<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"
T="string"
@bind-Value="_form.EventId"
Variant="Variant.Outlined"
Placeholder="26000000"
data-test="journal-add-event-id" />
<span class="m-journal__form-hint">@L["Journal.Field.EventId.Hint"]</span>
</div>
<div class="m-journal__form-field">
<label class="m-journal__form-label">@L["Journal.Field.Type"]</label>
<MudSelect T="BetType"
Value="_form.Type"
ValueChanged="OnTypeChanged"
Variant="Variant.Outlined"
data-test="journal-add-type">
@foreach (var betType in _betTypes)
{
<MudSelectItem T="BetType" Value="@betType">@BetTypeLabel(betType)</MudSelectItem>
}
</MudSelect>
</div>
<div class="m-journal__form-field">
<label class="m-journal__form-label">@L["Journal.Field.Side"]</label>
<MudSelect T="Side"
@bind-Value="_form.Side"
Variant="Variant.Outlined"
data-test="journal-add-side">
@foreach (var side in SidesFor(_form.Type))
{
<MudSelectItem T="Side" Value="@side">@SideLabel(side)</MudSelectItem>
}
</MudSelect>
</div>
@if (_form.Type is BetType.WinFora or BetType.Total)
{
<div class="m-journal__form-field">
<label class="m-journal__form-label">@L["Journal.Field.Value"]</label>
<MudNumericField T="decimal?"
@bind-Value="_form.Value"
Variant="Variant.Outlined"
Step="0.5m"
data-test="journal-add-value" />
<span class="m-journal__form-hint">@L["Journal.Field.Value.Hint"]</span>
</div>
}
<div class="m-journal__form-field">
<label class="m-journal__form-label">@L["Journal.Field.Rate"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.Rate"
Min="1.01m"
Step="0.01m"
Variant="Variant.Outlined"
data-test="journal-add-rate" />
</div>
<div class="m-journal__form-field">
<label class="m-journal__form-label">@L["Journal.Field.Stake"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.Stake"
Min="0.01m"
Step="1m"
Variant="Variant.Outlined"
data-test="journal-add-stake" />
</div>
<div class="m-journal__form-field m-journal__form-field--wide" data-test="journal-kelly">
<label class="m-journal__form-label">@L["Journal.Kelly.Title"]</label>
<div style="display:flex; flex-wrap:wrap; gap:var(--m-space-3); align-items:flex-end;">
<div style="flex:1 1 150px;">
<span class="m-journal__form-hint">@L["Journal.Kelly.Bankroll"]</span>
<MudNumericField T="decimal?"
@bind-Value="_kellyBankroll"
Min="0m" Step="50m"
Variant="Variant.Outlined"
data-test="journal-kelly-bankroll" />
</div>
<div style="flex:1 1 150px;">
<span class="m-journal__form-hint">@L["Journal.Kelly.Probability"]</span>
<MudNumericField T="decimal?"
@bind-Value="_kellyProbabilityPercent"
Min="0m" Max="100m" Step="1m"
Variant="Variant.Outlined"
data-test="journal-kelly-prob" />
</div>
</div>
@if (KellySuggestion is { } suggestion)
{
@if (suggestion > 0m)
{
<div style="margin-top:var(--m-space-2); display:flex; align-items:center; gap:var(--m-space-3); flex-wrap:wrap;">
<span class="m-journal__form-hint" data-test="journal-kelly-suggestion">
@string.Format(CultureInfo.CurrentCulture, L["Journal.Kelly.Suggestion"].Value, suggestion)
</span>
<button type="button" class="m-chip" @onclick="ApplyKellyStake" data-test="journal-kelly-apply">
@L["Journal.Kelly.Apply"]
</button>
</div>
}
else
{
<span class="m-journal__form-hint" data-test="journal-kelly-noedge">@L["Journal.Kelly.NoEdge"]</span>
}
}
else
{
<span class="m-journal__form-hint">@L["Journal.Kelly.Hint"]</span>
}
</div>
<div class="m-journal__form-field m-journal__form-field--wide">
<label class="m-journal__form-label">@L["Journal.Field.Notes"]</label>
<MudTextField T="string"
@bind-Value="_form.Notes"
Variant="Variant.Outlined"
Lines="2"
Placeholder="@L["Journal.Field.Notes.Placeholder"]"
data-test="journal-add-notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(_formError))
{
<p class="m-journal__form-error" data-test="journal-add-error">@_formError</p>
}
@if (_editingId is not null)
{
<p class="m-journal__form-hint" data-test="journal-editing-banner">@L["Journal.Editing"]</p>
}
<div class="m-journal__form-actions">
@if (_editingId is not null)
{
<button type="button"
class="m-chip m-journal__chip"
@onclick="CancelEdit"
data-test="journal-edit-cancel">
@L["Journal.Action.Cancel"]
</button>
}
<button type="button"
class="m-chip m-journal__submit"
@onclick="SubmitAsync"
disabled="@_submitting"
data-test="journal-add-submit">
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">@(_editingId is null ? "+" : "✓")</span>
<span>@(_editingId is null ? L["Journal.Action.Submit"] : L["Journal.Action.SaveEdit"])</span>
</button>
</div>
</article>
</section>
<hr class="m-rule--double" />
@* ---------- Bets list ---------- *@
<section class="m-journal__section m-rise m-rise-4" data-test="journal-list">
<header class="m-journal__section-head">
<span class="m-kicker">@L["Journal.Section.List"]</span>
<span class="m-journal__section-count m-mono">@vm.Bets.Count</span>
</header>
@if (vm.Bets.Count == 0)
{
<div class="m-list-empty" data-test="journal-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Journal.Empty.None"]
</p>
</div>
}
else
{
<div class="m-journal__table-wrap">
<table class="m-journal__table" data-test="journal-table">
<thead>
<tr>
<th scope="col">@L["Journal.Column.PlacedAt"]</th>
<th scope="col">@L["Journal.Column.Match"]</th>
<th scope="col">@L["Journal.Column.Selection"]</th>
<th scope="col" style="text-align: right;">@L["Journal.Column.Stake"]</th>
<th scope="col" style="text-align: right;">@L["Journal.Column.Rate"]</th>
<th scope="col" style="text-align: right;">@L["Journal.Column.Profit"]</th>
<th scope="col" style="text-align: right;">@L["Journal.Column.Clv"]</th>
<th scope="col">@L["Journal.Column.Outcome"]</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@foreach (var bet in vm.Bets)
{
var row = bet;
<tr class="m-journal__row m-journal__row--@OutcomeCss(row.Bet.Outcome)"
data-test="journal-row"
data-bet-id="@row.Id">
<td class="m-mono">@row.Bet.PlacedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 500;">@row.EventTitle</td>
<td>
<div class="m-journal__selection">@SelectionLabel(row.Bet.Selection)</div>
@if (!string.IsNullOrWhiteSpace(row.Bet.Notes))
{
<div class="m-journal__notes" data-test="journal-row-notes">@row.Bet.Notes</div>
}
</td>
<td class="m-mono" style="text-align: right;">@row.Bet.Stake.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono" style="text-align: right;">@row.Bet.Selection.Rate.Value.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono m-journal__pl m-journal__pl--@ProfitTone(row.Bet.NetProfit)" style="text-align: right;">
@FormatProfit(row.Bet.NetProfit)
</td>
<td class="m-mono m-journal__clv m-journal__clv--@ClvTone(row.ClvProbabilityDelta)" style="text-align: right;">
@FormatClvPoints(row.ClvProbabilityDelta)
</td>
<td>
<span class="m-journal__verdict m-journal__verdict--@OutcomeCss(row.Bet.Outcome)">
@OutcomeLabel(row.Bet.Outcome)
</span>
</td>
<td class="m-journal__row-actions">
@if (_pendingDeleteId == row.Id)
{
<span class="m-journal__confirm" data-test="@($"journal-delete-confirm-{row.Id}")">
<span class="m-journal__confirm-msg">@L["Journal.Confirm.Delete"]</span>
<button type="button"
class="m-chip m-journal__chip m-journal__chip--danger"
@onclick="@(() => ConfirmDeleteAsync(row.Id))"
data-test="@($"journal-delete-confirm-yes-{row.Id}")">
@L["Journal.Action.Confirm"]
</button>
<button type="button"
class="m-chip m-journal__chip"
@onclick="CancelDelete"
data-test="@($"journal-delete-confirm-no-{row.Id}")">
@L["Journal.Action.Cancel"]
</button>
</span>
}
else
{
<button type="button"
class="m-chip m-journal__chip m-journal__chip--ghost"
@onclick="@(() => BeginEdit(row))"
data-test="@($"journal-edit-{row.Id}")"
aria-label="@L["Journal.Action.EditBet"]">
<span aria-hidden="true">✎</span>
<span>@L["Journal.Action.EditBet"]</span>
</button>
<button type="button"
class="m-chip m-journal__chip m-journal__chip--ghost"
@onclick="@(() => RequestDelete(row.Id))"
data-test="@($"journal-delete-{row.Id}")"
aria-label="@L["Journal.Action.Delete"]">
<span aria-hidden="true">×</span>
<span>@L["Journal.Action.Delete"]</span>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
</section>
<style>
/* ---- Header ---- */
.m-journal__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--m-space-5);
align-items: end;
}
@@media (max-width: 720px) {
.m-journal__header { grid-template-columns: 1fr; }
.m-journal__header-actions { justify-self: start; }
}
.m-journal__header-text { display: grid; gap: var(--m-space-3); max-width: 880px; }
.m-journal__header-actions { display: flex; gap: var(--m-space-3); flex-wrap: wrap; }
.m-journal__chip {
gap: var(--m-space-2);
padding: 6px 12px;
font-family: var(--m-font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-journal__chip:disabled { opacity: 0.6; cursor: progress; }
.m-journal__chip--ghost {
color: var(--m-c-ink-soft);
background: transparent;
}
.m-journal__chip--ghost:hover {
color: var(--m-c-anomaly);
border-color: var(--m-c-anomaly);
}
.m-journal__chip--danger {
color: var(--m-c-anomaly);
border-color: var(--m-c-anomaly);
}
.m-journal__chip-glyph {
display: inline-block;
font-size: 0.875rem;
line-height: 1;
transition: transform 200ms ease;
}
.m-journal__chip:hover .m-journal__chip-glyph { transform: rotate(45deg); }
.m-journal__chip-glyph.is-spinning { animation: m-journal-spin 1.1s linear infinite; }
@@keyframes m-journal-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@media (prefers-reduced-motion: reduce) {
.m-journal__chip-glyph.is-spinning { animation: none; }
.m-journal__chip:hover .m-journal__chip-glyph { transform: none; }
}
/* ---- KPI strip ---- */
.m-journal__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-journal__kpi {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
display: flex;
flex-direction: column;
gap: var(--m-space-2);
position: relative;
}
.m-journal__kpi--positive { border-left-color: var(--m-c-positive); }
.m-journal__kpi--neutral { border-left-color: var(--m-c-accent); }
.m-journal__kpi--negative { border-left-color: var(--m-c-anomaly); }
.m-journal__kpi-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-journal__kpi-value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(2rem, 3.5vw, 2.625rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: var(--m-c-ink);
}
.m-journal__kpi--positive .m-journal__kpi-value { color: var(--m-c-positive); }
.m-journal__kpi--negative .m-journal__kpi-value { color: var(--m-c-anomaly); }
.m-journal__kpi-hint {
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
}
/* ---- Mono count strip ---- */
.m-journal__counts {
display: flex;
gap: var(--m-space-3);
flex-wrap: wrap;
align-items: baseline;
padding: var(--m-space-2) 0;
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
font-feature-settings: var(--m-num-feature);
}
.m-journal__counts strong {
color: var(--m-c-ink);
font-weight: 600;
}
.m-journal__counts-label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.6875rem;
}
/* ---- Section headers ---- */
.m-journal__section { display: grid; gap: var(--m-space-4); }
.m-journal__section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--m-space-3);
}
.m-journal__section-count {
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
/* ---- Form ---- */
.m-journal__form-card {
display: grid;
gap: var(--m-space-4);
padding: var(--m-space-5);
}
.m-journal__form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--m-space-4);
}
.m-journal__form-field { display: grid; gap: var(--m-space-2); }
.m-journal__form-field--wide { grid-column: span 2; }
@@media (max-width: 720px) {
.m-journal__form-field--wide { grid-column: span 1; }
}
.m-journal__form-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-journal__form-hint {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-journal__form-error {
margin: 0;
padding: var(--m-space-3) var(--m-space-4);
border: 1px solid var(--m-c-anomaly);
border-left-width: 3px;
background: rgba(220, 38, 38, 0.06);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
line-height: 1.5;
}
[data-theme="dark"] .m-journal__form-error {
background: rgba(248, 113, 113, 0.10);
}
.m-journal__form-actions {
display: flex;
justify-content: flex-end;
gap: var(--m-space-3);
}
.m-journal__submit {
border-color: var(--m-c-accent);
color: var(--m-c-accent);
}
.m-journal__submit:not(:disabled):hover {
background: var(--m-c-accent);
color: var(--m-c-paper);
}
/* ---- Table ---- */
.m-journal__table-wrap {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-journal__table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-journal__table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-journal__table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-journal__table tbody tr:last-child td { border-bottom: 0; }
.m-journal__row { transition: background 120ms ease; }
.m-journal__row:hover { background: var(--m-c-paper-2); }
.m-journal__row--won { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
.m-journal__row--lost { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
.m-journal__row--pending { box-shadow: inset 2px 0 0 0 var(--m-c-rule); }
.m-journal__row--void { box-shadow: inset 2px 0 0 0 var(--m-c-ink-soft); }
@@media (prefers-reduced-motion: reduce) {
.m-journal__row { transition: none; }
}
.m-journal__pl, .m-journal__clv { font-feature-settings: var(--m-num-feature); font-weight: 600; }
.m-journal__pl--positive, .m-journal__clv--positive { color: var(--m-c-positive); }
.m-journal__pl--negative, .m-journal__clv--negative { color: var(--m-c-anomaly); }
.m-journal__pl--neutral, .m-journal__clv--neutral { color: var(--m-c-ink-soft); }
.m-journal__selection { font-weight: 500; }
.m-journal__notes {
margin-top: 2px;
font-size: 0.75rem;
color: var(--m-c-ink-soft);
font-style: italic;
max-width: 36ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-journal__verdict {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
background: rgba(0, 0, 0, 0);
}
.m-journal__verdict--won {
color: var(--m-c-positive);
background: rgba(21, 128, 61, 0.10);
}
.m-journal__verdict--lost {
color: var(--m-c-anomaly);
background: rgba(220, 38, 38, 0.10);
}
.m-journal__verdict--pending {
color: var(--m-c-ink-soft);
background: transparent;
}
.m-journal__verdict--void {
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
}
[data-theme="dark"] .m-journal__verdict--won {
color: var(--m-c-positive);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .m-journal__verdict--lost {
color: var(--m-c-anomaly);
background: rgba(248, 113, 113, 0.15);
}
.m-journal__row-actions {
text-align: right;
white-space: nowrap;
}
.m-journal__confirm {
display: inline-flex;
align-items: center;
gap: var(--m-space-2);
flex-wrap: wrap;
justify-content: flex-end;
}
.m-journal__confirm-msg {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--m-c-anomaly);
}
/* ---- Empty-state block ---- */
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
}
</style>
@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;
/// <summary>
/// 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.
/// </summary>
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<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()
{
_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<Side> 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();
}
}