Commit Graph

28 Commits

Author SHA1 Message Date
alexei.dolgolyov 2e53dff853 feat(settings): validate BaseUrl + cron on save, add BaseUrl hint
- Reject a non-absolute / non-http(s) BaseUrl and an implausible (not 5- or
  6-field) cron expression before the section is written to disk, mirroring the
  existing storage-path validation (snackbar + early return).
- Add a hint to the BaseUrl field. Cron check is a lightweight UI guard; the
  worker still does the authoritative Cronos parse at startup.
2026-05-29 00:50:49 +03:00
alexei.dolgolyov e5cd2ab30c feat(backtest): optional date-range window
- RunBacktestUseCase gains an ExecuteAsync(strategy, DateRange?, ct) overload that
  pushes the date filter to SQL via IAnomalyRepository.ListByDateRangeAsync; the
  existing no-range overload is preserved. +1 use-case test.
- BacktestForm carries optional From/To (Moscow dates) with From<=To validation and
  a ToDateRange() helper; BacktestService threads it through. Backtest page gains two
  clearable date pickers (empty = all anomalies).
- Localization (en+ru) for the backtest date fields and the settings-validation keys
  (shared resx).
2026-05-29 00:50:43 +03:00
alexei.dolgolyov d9d92ea8fd feat(ui): event autocomplete + log-bet deep link, steam-move label
- MyBets: add a "Find event" MudAutocomplete over upcoming events (loaded once,
  filtered client-side) that fills the Event ID; the manual ID field stays as a
  fallback. Backed by IBetJournalService.GetUpcomingEventOptionsAsync.
- Add a "Log bet" CTA on the anomaly detail page that deep-links to
  /my-bets?eventId=<code>; the journal prefills the Event ID from the query.
- Render the new SteamMove anomaly kind with a localized label in the card and
  detail KindLabel switches (was falling through to the raw enum name).
- Localization (en+ru) for all new strings.
2026-05-28 23:08:56 +03:00
alexei.dolgolyov 4dae9e8d0d feat(ui): promote Excel export to a top-level destination
- Add an /export hub page that hosts the existing (date-range, not event-specific)
  ExportDialog, so export is no longer reachable only by opening an event detail.
- Add an Export entry under the System nav section.
2026-05-28 22:46:38 +03:00
alexei.dolgolyov 0e3c4b8d47 feat: Kelly criterion stake sizing (domain + MyBets helper)
- Add KellyCalculator (Domain/Betting): pure fractional-Kelly stake from win
  probability, decimal odds, bankroll, and fraction (default quarter-Kelly).
  Returns 0 on non-positive edge; truncates the suggestion down to 2 decimals
  so it never exceeds the computed figure. 19 unit tests.
- MyBets: add a page-local stake helper (bankroll + win-probability inputs) that
  suggests a quarter-Kelly stake from the form's rate, with an Apply button and a
  no-edge message. Win probability is user-supplied, not derived from a signal.
- Localization (en+ru) for the Kelly helper and the export-hub keys (shared resx).
2026-05-28 22:46:33 +03:00
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
alexei.dolgolyov 0501f9c39c refactor: log silenced UI errors, fix timer leak, narrow exception catch
- EventListShell: detach the Elapsed handler before disposing the refresh timer
  (both StartTimer and Dispose) to stop a leaked subscription firing on a
  torn-down component; log the two previously-silent catches.
- Insights: log the previously-silent report-load catch.
- EventOddsParser: narrow catch(Exception) to catch(ArgumentException) so only
  the OddsRate/OddsValue/Bet guard-clause throws are swallowed.
- AnomalyEvidenceData: make the JSON DTOs init-only per the immutability convention.
- Settings: remove a dead DialogParameters block.
2026-05-28 22:34:17 +03:00
alexei.dolgolyov f294255f10 perf: batch repository reads, index snapshots, centralize date encoding
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
  6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
  results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
  load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
  (feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
  _EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
  shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
  DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.

Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
2026-05-28 22:34:08 +03:00
alexei.dolgolyov 0d52b7beff feat(backtest): historical strategy backtester
Adds an interactive backtester that replays the SuspensionFlip detector over
all flagged anomalies under a chosen score threshold and staking rule
(flat / percent-of-bankroll / Kelly), and reports the headline numbers a
user needs to judge edge: final bankroll, ROI, max drawdown (peak-to-trough),
win/loss streaks, plus per-bet equity curve.

Domain (pure):
- StakeRule enum + BacktestStrategy params (with validation).
- BacktestSimulator: deterministic function taking strategy + chronological
  candidates → BacktestResult. Implements Kelly with post-flip implied prob
  as p (skipping negative-edge bets), peak-to-trough drawdown tracking, and
  win/loss streak rollups. Mirrors AnomalyOutcomeEvaluator on the 2-way Draw
  guard so tennis data inconsistencies are refused rather than miss-counted.
- Skipped counter split into SkippedByThreshold / SkippedByDataQuality /
  SkippedByBankroll so the UI can distinguish "strategy choice" from
  "data-quality" from "bankroll empty".

Application:
- RunBacktestUseCase: loads anomalies + events + results, parses evidence,
  builds candidates, hands event titles into the simulator so the UI does
  zero repository round-trips of its own.

UI:
- Pages/Anomalies/Backtest.razor: hero, strategy form (MudBlazor — conditional
  sub-field per staking rule), 4-card KPI strip (final bankroll / net profit
  / ROI / max drawdown), counters row, inline-SVG equity curve, trade-trace
  table with per-bet outcome pills and link-back to the source anomaly.
- Nav entry under Analysis. RU + EN i18n.

Tests: +20 (16 simulator math — flat / percent compounding / Kelly +/-
edge / quarter-Kelly / bankroll-exceeded / out-of-order chronology / Draw
favourite / multi-window drawdown / event-title pass-through + 4 use-case
join). All 399 tests pass.

Money rounding switched to MidpointRounding.AwayFromZero throughout the
simulator output for accounting convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:34:42 +03:00
alexei.dolgolyov 1ad896b07e feat(my-bets): personal bet journal with CLV tracking
Adds a manual bet-tracking journal that turns the analyzer into an actual
bet tracker. Users record wagers; the journal auto-grades them when event
results land and computes per-bet Closing-Line-Value against the latest
pre-match snapshot — the strongest long-run indicator of betting skill.

Domain:
- PlacedBet entity (reuses Bet vocabulary for Scope/Type/Side/Value/Rate)
  with stake, placed-at, outcome, and notes. Derived GrossReturn / NetProfit.
- BetOutcome enum (Pending / Won / Lost / Void).
- BetOutcomeResolver: pure function grading any Match-scope bet against an
  EventResult. Handles 1X2, draws, handicap (incl. push), and totals.
  Period-scope bets stay manual since EventResult only carries full-time.

Application:
- IPlacedBetRepository abstraction.
- ClosingLineValueCalculator: pure CLV math (implied-probability delta) +
  snapshot-matching predicate by Scope/Type/Side/Value.
- BetJournalReport + BetJournalStats records.
- Four use cases: Record / ResolvePending / BuildReport / Delete.
- New ISnapshotRepository.GetLatestPreMatchAsync pushes the closing-line
  pick into a single SQLite query rather than materialising the 30-day
  window in memory per event.
- ROI turnover excludes Void stakes — pushes are not real turnover and
  including them would dilute the user's edge.

Infrastructure:
- PlacedBetEntity / Configuration / Repository / Mapping helpers.
- 20260516 migration adding the PlacedBets table with EventCode and
  Outcome indices. Intentionally NO foreign key to Events — the journal
  is user data and must survive snapshot-retention pruning. Covered by an
  explicit round-trip test.

UI:
- Pages/MyBets/Journal.razor: hero header, 4-card KPI strip (ROI / strike
  rate / avg CLV / net profit, tinted by tone), inline add-bet form with
  the same invariants as the Bet record, drill-down table with per-row
  outcome pills, CLV percentage-points column, P&L, notes underline, and
  inline-confirm delete. RU + EN i18n.
- Nav entry under Analysis.

Tests: +55 across Domain / Application / Infrastructure (resolver math
including handicap push and total push boundaries, PlacedBet invariants
and derived properties, CLV math + null-handling, four use cases under
NSubstitute, EF round-trip including survives-event-deletion). All 379
tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:45:42 +03:00
alexei.dolgolyov 292223174c feat(insights): anomaly outcome validator — hit-rate calibration page
Adds a calibration dashboard that joins persisted SuspensionFlip anomalies
with EventResult rows and reports whether the post-flip favourite actually
won — the single metric that says whether the detector is doing its job.

Domain:
- AnomalyEvidenceData + AnomalyEvidenceParser to read the JSON written by
  AnomalyDetector without re-implementing the schema.
- AnomalyOutcomeEvaluator: pure function returning Hit / Miss / Unresolved.
  Tennis-style two-way markets with a Draw winner are downgraded to
  Unresolved rather than silently counted as Miss.
- AnomalySeverityThresholds: shared Low/Medium/High constants so the UI
  badge and the report buckets cannot drift.

Application:
- EvaluateAnomalyOutcomesUseCase orchestrates the join + aggregation.
- AnomalyOutcomeReport carries totals, hit rate, three breakdowns
  (severity / sport / score bins) and a per-event title lookup so the UI
  needs no second pass over IEventRepository.
- Score bins extend below 0.30 automatically when the operator lowers the
  detector threshold so the histogram total always equals ResolvedCount.

UI:
- Insights page at /anomalies/insights — hero header, 4-card KPI strip
  (hit rate tinted by tone), three breakdown grids with bar visualisation,
  drill-down tables for resolved and unresolved anomalies. Honors
  prefers-reduced-motion. RU + EN localisation.
- Nav entry under Analysis section + chip button on the Anomaly Feed.

Tests: +42 across Domain + Application (evaluator boundary cases including
tennis two-way and Draw guard, score-bin edges, dynamic floor when
threshold is lowered, event-title pass-through). All 324 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:53:31 +03:00
alexei.dolgolyov 004dbeae8b fix(scraping): live page lacks data-event-path and uses category sport IDs
Previously LiveEventsParser returned 0 events from /su/live because two
real differences between the live page and the pre-match listing weren't
handled:

1. Live rows omit data-event-path entirely. They expose only
   data-event-treeId, and the bookmaker routes live events under
   /su/live/<treeId> rather than /su/betting/<...>.

2. The closest data-sport-treeId ancestor on the live page is a
   category-tree wrapper (26418=Football-live, 45356=Basketball-live, …)
   instead of the canonical breadcrumb sport ID (11/6/22723/43658) the
   rest of the app uses. The pre-match listing carries the canonical
   ID directly.

Changes:

* EventListingParserBase.ParseRow: data-event-path becomes optional. For
  live rows we synthesize EventPath = "live/<treeId>" from
  data-event-treeId (validated as digits-only). Pre-match validation is
  unchanged.

* New ExtractSportCodeFromLive walks ancestors looking for a sport-tree
  ID and maps it through a small live-id → canonical-id table covering
  the four scoped sports. Out-of-scope sports (cybersport, volleyball,
  table tennis) are intentionally left unmapped — they keep their raw
  category ID and the UI renders them via SportLabels as "Sport <N>".

* MarathonbetScraper.ResolveEventDetailPath: dispatches between
  /su/live/<treeId> and /su/betting/<...> based on the EventPath prefix.
  Removes the duplicated path-building between ScrapeEventOddsAsync and
  ScrapeEventResultAsync.

* New regression tests covering all three behaviors against a real
  /su/live capture (16 events, 5 sport categories).

Also: rewrites the stale "Disabled until Phase 8" hint copy on the
Settings.Workers.ResultsPollerEnabled flag — Phase 8 shipped, so the
results poller is safe to enable.
2026-05-09 16:07:03 +03:00
alexei.dolgolyov 537b78ab83 fix(security): validate scraped paths, BaseUrl, settings paths + atomic write
Five MEDIUM-tier security findings from the review. None were exploitable in
the single-user desktop threat model, but each is hardening for future hosts
(server / multi-user) and for the case of an upstream compromise.

* EventListingParserBase: scraped data-event-path values are now run through
  IsSafeRelativePath before being concatenated into request URLs. Rejects
  scheme://host/* patterns, leading slash/backslash (network-path traversal),
  ".." traversal, control characters (CRLF log forging), and path lengths
  greater than 512.

* ScrapingModule.ResolveBaseAddress: configured Scraping:BaseUrl is now
  validated through an https-only allow-list (marathonbet.by + subdomains).
  Falls back to the default base URL when the value is missing, malformed,
  off-host, or carries userinfo / query / fragment. Settings UI may write
  arbitrary strings to this key — a typo or worse could otherwise re-point
  every subsequent request at an unrelated host.

* JsonSettingsWriter.WriteRootAsync: temp-file write now FlushAsync +
  Flush(flushToDisk: true) before the rename so a crash mid-write doesn't
  leave a 0-byte appsettings.Local.json. Promote the rename from File.Move
  (overwrite=true, not atomic on NTFS for cross-volume cases) to
  File.Replace (atomic on NTFS, keeps a .bak copy of the previous file).
  Falls back to File.Move when the destination doesn't exist yet.

* StoragePathValidator + Settings.razor wiring: DatabasePath and
  ExportDirectory paths from the Settings page are validated before persist.
  Rejects empty values, ".." traversal, control characters, and any path
  that resolves outside AppContext.BaseDirectory. Adds 4 new RU+EN keys for
  the validation messages.

* Logged scraper paths: covered transitively by the EventPath validation
  above (the upstream of every logged {Path} value), so no separate
  sanitization needed.
2026-05-09 15:50:52 +03:00
alexei.dolgolyov c2934b2c8d chore(med): mapping culture-safe parse, dead-code, scope comparer, UA rotator, parser cache
Six MEDIUM-tier review items:

* Mapping.cs — DateTimeOffset.Parse now passes CultureInfo.InvariantCulture
  + DateTimeStyles.RoundtripKind so a non-en-US thread culture cannot
  corrupt round-tripped ScheduledAt / CapturedAt / DetectedAt / CompletedAt.
  Also replaces the magic 0/1 BetScope discriminator with named constants.

* Delete dead Placeholder.cs files in Marathon.Application and
  Marathon.Infrastructure — they were stubs from Phase 1 to satisfy
  "non-empty project" and have been dead since Phase 2/3.

* EventBrowsingService — drop the bespoke ScopeEqualityComparer; BetScope
  is a record hierarchy, .GroupBy uses value equality natively.

* UserAgentRotatorHandler — counter promoted to private static int with
  Interlocked.Increment so rotation is round-robin across the process.
  HttpClientFactory builds the handler Transient, so the previous instance
  field reset to zero on every new client and broke rotation.

* EventOddsParser — added a parallel "selection-key → IElement" index
  alongside the existing price index. Handicap extraction (6 call sites
  per event detail page) used to do a fresh document.QuerySelector("span[
  data-selection-key='...']") for every key — full-document CSS traversal.
  Now it's a dictionary lookup, with the pair-emit logic factored into a
  shared TryEmitHandicapPair helper.
2026-05-09 15:45:18 +03:00
alexei.dolgolyov fed3a09695 refactor: hoist Moscow offset + sport labels into shared helpers (HIGH)
* New Marathon.Domain.ValueObjects.MoscowTime with Offset, Now, and
  EndOfMoscowDay(DateOnly) — replaces ~15 inline TimeSpan.FromHours(3)
  literals across Domain/Application/Infrastructure/UI.
* New Marathon.UI.Services.SportLabels.Resolve(IStringLocalizer, int) —
  replaces 6 near-identical SportLabel switch bodies in EventListShell,
  Events/Detail, Anomalies/AnomalyFeed, Results/ResultsList,
  Results/ResultsLoader, and AnomalyCard. Single source of truth for the
  6/11/22723/43658 sport-code mapping. Pages keep a one-liner wrapper so
  the call sites stay terse.
2026-05-09 15:40:35 +03:00
alexei.dolgolyov d1e6ce7ce2 fix(ui): EventListShell — reload on Filter swap + harden refresh-timer (HIGH+MED)
* Filter changes from the parent (page-state singleton swap) now trigger
  LoadAsync via OnParametersSetAsync once the component has rendered.
  Previously the reload happened only on first render, so navigating back
  to a list page with a different sport/date filter showed the prior data.
* Refresh-timer Elapsed handler is hoisted to a named async-void method
  with try/catch around InvokeAsync. An unhandled exception on the timer
  thread used to crash WebView2; now it's logged and swallowed.
* Overlapping ticks are skipped via a _loading short-circuit so a slow
  Loader doesn't stack up cancelled-superseded loads.
2026-05-09 15:31:48 +03:00
alexei.dolgolyov 857d456b95 perf(ui): batch event-list snapshot loads + push distinct dimensions to DB (HIGH)
* EventBrowsingService.BuildListAsync issued one snapshot query per event
  on every page render — N+1 against SQLite, with each round-trip hauling
  the full bet graph via Include(Bets). Replaced with a single
  ISnapshotRepository.ListByEventsAsync batch.
* ListKnownSportCodesAsync / ListKnownCountryCodesAsync used to materialise
  every Event row to compute Distinct() in memory. Pushed to EF projection
  via two new IEventRepository methods (ListDistinctSportCodesAsync,
  ListDistinctCountryCodesAsync) implemented as
  .Select(...).Distinct().ToListAsync — single SELECT DISTINCT.
2026-05-09 15:29:05 +03:00
alexei.dolgolyov 958d472582 fix(ui): MainLayout — declare @implements IDisposable so Dispose actually runs (HIGH)
The component had a Dispose() method that detached ThemeState/LocaleState
event handlers, but it was never invoked because the @implements directive
was missing. Each navigation through the layout leaked two subscriptions
on the singleton state objects.

Other state subscribers (NavBody.razor, AnomalyFeed.razor) already declare
the directive correctly.
2026-05-09 15:14:34 +03:00
alexei.dolgolyov a627c360c3 fix(ui): invariant decimals + LocaleState test isolation + drawer offset + bundled Plotly
Cross-cutting polish that surfaced while Phase 8 was being implemented:

* Invariant-culture formatting on every decimal ToString("0.00" / "0.##")
  (OddsCell, OddsTimeline, SeverityBadge, AnomalyEvidence,
  Pages/Events/Detail) — previously the comma/dot decimal separator
  switched with the locale and broke tabular alignment + tests.

* LocaleState constructor no longer mutates process-wide ambient culture;
  apply only happens through Set(...). Stops parallel bUnit test runs
  leaking ru-RU into each other's threads.

* MainLayout: drawer-open state now offsets main content / footer / appbar
  by the drawer width (248px) so the sidebar no longer overlaps content.
  Mobile breakpoint (≤720px) keeps the original full-width layout.

* wwwroot/index.html (Marathon.UI RCL): switched from Plotly CDN to the
  bundled "_content/Plotly.Blazor/plotly-2.35.3.min.js" — works offline
  and matches the Plotly.Blazor 5.4.1 version pin.

* Marathon.Hosts.WpfBlazor/wwwroot/index.html: host-level page that
  BlazorWebView's HostPage attribute resolves to. Same Plotly bundle,
  no autostart="false" (BlazorWebView auto-starts).
2026-05-09 15:11:13 +03:00
alexei.dolgolyov 9f090cec1f feat(phase-8-frontend): results loader UI + browsing list + 41 localization keys
* Pages/Results/ResultsList.razor — completed-events list with date range,
  sport/winner filter, search, footer count.
* Pages/Results/ResultsLoader.razor — driver page with two modes (load all
  in range / load selected events), live progress reporting via
  IProgress<PullResultsProgress>, summary line, cancellable.
* Replaces the Phase 5 Pages/Results.razor placeholder.

Service layer:
* IResultsBrowsingService + ResultsBrowsingService (Scoped, mirrors the
  Event/Anomaly browsing-service pattern). Reads IResultRepository +
  IEventRepository, projects to immutable view-model records.
* UiServicesExtensions: registers ResultsBrowsingService; also fixes an
  unrelated localization resolver bug (drop ResourcesPath since
  SharedResource lives in the Marathon.UI.Resources namespace already).

Localization:
* 41 new Results.* keys (RU+EN parity) covering both pages, filter chips,
  loader modes, progress states, and footer copy.

Tests:
* ResultsListTests + ResultsLoaderTests — 22 new bUnit tests covering
  filter narrowing, mode switching, progress aggregation, and empty
  states.
* FakeResultsBrowsingService support type for tests.
* MarathonTestContext registers the fake; TestData adds factories for
  EventResult/EventResultListItem.
2026-05-09 15:10:49 +03:00
alexei.dolgolyov 85bc99cac5 fix(host): wire DB migration init + Plotly CDN + attribute fix on hand-written migration
Three fixes surfaced when launching the WPF host for the first time:

1. App.xaml.cs — call MarathonDbContextInitializer.InitializeAsync()
   between Host.Build() and Host.Start() so EF migrations + WAL pragma
   are applied BEFORE BackgroundServices race to query the DB. Without
   this, all pollers crashed on 'no such table: Events'.

2. wwwroot/index.html — added <script src='https://cdn.plot.ly/plotly-2.35.2.min.js'>
   before blazor.webview.js. Phase 6 reviewer flagged this for Phase 9,
   but charts are unrenderable without it; better to ship now.

3. Migrations/20260505000000_InitialCreate.cs — added [DbContext] and
   [Migration('20260505000000_InitialCreate')] attributes. Phase 2's
   hand-written migration was missing both, so EF saw 'no migrations to
   apply' even on a fresh DB. With the attributes, the migration runs
   on first launch and creates all tables (Events, Snapshots, Bets,
   EventResults, Anomalies, Sports, Leagues).

Verified: clean DB → migration applied → all 7 tables created → pollers
run with empty results (no data yet — UpcomingEventsPoller fires every 6h
by default; first scrape will populate the DB).
2026-05-05 13:55:59 +03:00
alexei.dolgolyov 12208a4762 feat(phase-7-frontend): anomaly feed UI + nav badge + Settings toggle (+31 bUnit tests)
Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.

New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
  Phase 5 placeholder with a severity-coded card stream, filter chips
  (severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
  lockup + AnomalyEvidence panel + back link to /events/{eventCode}.

New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
  amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
  pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
  handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
  bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
  signal-red 3px left border on the post column. Handles 2-way (no draw).

State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
  / AnomalyEvidenceSnapshot record. Severity computed in the view-model
  from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
  parses Anomaly.EvidenceJson into typed view-models, applies filters
  client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
  GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
  sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.

Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
  pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
  user clicks 'Mark all read' on the feed toolbar.

Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
  added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
  (default true).

Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.

Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.

Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.

Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).

Phase 7 status:  Done (backend + frontend both complete, awaiting review).

Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
2026-05-05 13:39:39 +03:00
alexei.dolgolyov 553db2bce3 feat(phase-6): event browsing UI — pre-match/live lists, detail page, +26 bUnit tests
Replaces PreMatch/Live placeholder pages with a shared EventListShell
(filter chips, date range, sortable virtualized-friendly table, debounced
search, live auto-refresh with odds-movement indicators) and adds a new
/events/{eventCode} detail page (asymmetric header lockup, dynamic
Match/Period tabs, Plotly.Blazor odds-over-time chart with accessible
data-table fallback, snapshot history, Excel export modal).

New primitives matching Phase 5's editorial-quant system:
- SportIcon: inline SVGs per sport (basketball=6, football=11,
  tennis=22723, hockey=43658, generic fallback)
- OddsCell: tabular mono with ▲/▼/— delta + flash on change
  (prefers-reduced-motion honored)
- OddsTimeline: Plotly.Blazor wrapper with theme-aware colors and
  <details>/<summary> data-table screen-reader fallback
- ExportDialog: From/To pickers + ExportKind radio + Esc/Enter
  keyboard, surfaces use-case errors inline
- EventListShell: shared section shell for PreMatch/Live cadence

State + service split keeps the RCL host-agnostic:
- IEventBrowsingService / EventBrowsingService — wraps repos, returns
  view-model records (EventListItem, EventDetail, EventScopeBoard,
  BetRow, OddsTimelinePoint, SnapshotHistoryEntry); pages never see
  EF or domain entities directly.
- EventBrowsingState — singleton (per-circuit in BlazorWebView) holding
  immutable PageFilter records for PreMatch and Live.

Plotly.Blazor 5.4.1 added (latest .NET 8 line; 7.x has breaking changes).
+59 RU/EN localization keys following the Phase 5 dot-segmented convention.

Tests: +26 bUnit tests (PreMatch/Live/Detail pages, OddsCell/SportIcon/
ExportDialog components, EventBrowsingState). Total 228/228 passing
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline 202).
Build clean (0/0).

PLAN.md: P2/P3/P5 top-level checkboxes ticked; P6 row marked Done.
2026-05-05 12:58:03 +03:00
alexei.dolgolyov fe97643a41 fix(phase-4): close review notes — drop dead var, sync UI cron default
Phase 4 reviewer (Sonnet) flagged two 🟡 warnings; both addressed:

1. PullLiveOddsUseCase: removed dead 'liveEvents' assignment that called
   ScrapeUpcomingAsync but never read the result. Replaced misleading
   comment block with a single TODO(phase-6/8) note pointing to the
   ListLiveAsync(cutoff) follow-up.
2. Marathon.UI.Services.WorkerOptions.UpcomingScheduleCron: changed default
   from '0 */5 * * * *' (every 5 min) to '0 0 */6 * * *' (every 6 hours)
   to match Marathon.Infrastructure.Configuration.WorkerOptions and the
   appsettings.json default.

PLAN.md: Phase 4 row updated (review status, commit hash); top-level
checkbox in Phases list ticked.

Build 0/0, all 202 tests still passing.
2026-05-05 12:35:01 +03:00
alexei.dolgolyov 2acbaa5b77 feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
2026-05-05 12:28:15 +03:00
alexei.dolgolyov e4d8476782 WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed
Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.

Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)

Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)

Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
  Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
  (SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
  with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
  JsonSettingsWriterTests + Support helpers

Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
   them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.

Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
2026-05-05 01:56:53 +03:00
alexei.dolgolyov 9614b8cf37 chore(initial-implementation): remove RCL boilerplate, close phase 1 tracking
Per Phase 1 reviewer notes — strips Component1.razor, ExampleJsInterop, and
the wwwroot template assets generated by 'dotnet new razorclasslib'. Phase 5
will populate Marathon.UI from scratch with the real layout, components, and
wwwroot/index.html for BlazorWebView. Build still green (0/0).
2026-05-05 01:26:55 +03:00
alexei.dolgolyov 61114ea31b feat: implement Phase 1 — solution skeleton and domain model
Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain
fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue,
BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and
entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult,
Anomaly) with all invariants enforced in constructors. 96 domain tests pass
(FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props
centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx
are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.
2026-05-05 01:20:28 +03:00