Files
maraphon-app/src/Marathon.UI/Pages/MyBets/Journal.razor
T
alexei.dolgolyov 250a93e718 feat(ui): live dashboard, capture-status pill, bet/backtest UX
- Add IDashboardSummaryService/DashboardSummaryService: real event/snapshot/
  anomaly counts, top-5 signals, and per-stage pipeline health from worker state.
- Home: replace hard-coded zeros + placeholder feed with live data, a clickable
  signal feed, and a first-run empty state with a Settings CTA.
- MainLayout: add an appbar capture-status pill (Capturing/Paused) bound to the
  poller toggles, refreshed via IOptionsMonitor.OnChange.
- MyBets: success snackbar on bet submit. Backtest: surface a Cancel button
  while a run is in flight.
- Add en/ru localization for all new strings; register IOptionsMonitor<WorkerOptions>
  in the bUnit test context for layout-rendering tests.
2026-05-28 22:34:28 +03:00

933 lines
37 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
@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" 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">
<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>
}
<div class="m-journal__form-actions">
<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">+</span>
<span>@L["Journal.Action.Submit"]</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="@(() => 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 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<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();
}
}