1092e2a2c5
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.
1115 lines
45 KiB
Plaintext
1115 lines
45 KiB
Plaintext
@*
|
||
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();
|
||
}
|
||
}
|