* 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.
* 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.
* 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.
The Pull*UseCase implementations issued one HTTP request at a time despite
Scraping:MaxConcurrentRequests=4. With 30–80 live events and ~1s per
fetch, a 5–10s live cadence target was unreachable; cycles overflowed
the configured interval.
* New Marathon.Application.Configuration.ScrapingThrottle bound from the
shared Scraping:* section. Exposes only MaxConcurrentRequests so the
Application layer doesn't pull in the Infrastructure-side ScrapingOptions.
* PullLiveOddsUseCase + PullUpcomingEventsUseCase split into two phases:
- Phase 1 — Parallel.ForEachAsync over the event list with
MaxDegreeOfParallelism = throttle.MaxConcurrentRequests. The scraper's
Polly rate limiter still throttles to RequestsPerSecond underneath
this fan-out, so spikes are smoothed before they hit the bookmaker.
- Phase 2 — sequential foreach over the (Event, Snapshot) tuples
captured in Phase 1, doing event upsert + snapshot insert. EF Core
DbContext is not thread-safe so all DB writes stay on a single thread.
* InfrastructureModule binds ScrapingThrottle alongside AnomalyOptions.
* Failed snapshot scrapes in Phase 1 mean the event row is also NOT
persisted in Phase 2 — previously we'd persist the row even when the
snapshot scrape failed, leaving an orphan event with no odds. Updated
the regression test accordingly.
* Test fixture exposes TestFixtures.Throttle(maxConcurrentRequests=1) for
deterministic sequential test runs.
* One existing NSubstitute setup that chained Arg.Is<>() across two
configurations was rewritten to use a single Arg.Any<>() with inline
branching — chained matchers were leaking and returning wrong results.
DetectAnomaliesUseCase was issuing one ISnapshotRepository.ListByEventAsync
call per event each cycle, with each call rehydrating that event's bets via
Include(s => s.Bets) — O(N) SQLite round-trips and N Include payloads on
every detection cycle.
* Add ISnapshotRepository.ListByEventsAsync(IReadOnlyCollection<EventId>, …)
returning a per-event dictionary; events with no snapshots in range get
Array.Empty<OddsSnapshot>() so the caller doesn't need a presence check.
* Implementation uses a single .Where(s => ids.Contains(s.EventCode))
query and groups in memory.
* DetectAnomaliesUseCase loads the whole batch once before the foreach,
then ProcessEventAsync receives the per-event slice as a parameter.
* Tests updated to stub the new method; per-event-failure test now
exercises an AddAsync throw rather than a snapshot-load throw, since
individual snapshot loads no longer fail per-event.
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.
Snapshots are append-only and identified by the composite (EventId, CapturedAt),
not a surrogate Guid. The previous implementation inherited
IRepository<Guid, OddsSnapshot> and faked the lookup with (long)key.GetHashCode()
on a long auto-increment PK — collision-prone and non-portable. Nobody called
GetAsync(Guid) / DeleteAsync(Guid) anyway.
* ISnapshotRepository no longer extends IRepository<Guid, OddsSnapshot>; it
exposes only the methods snapshots actually have: ListAsync, ListByEventAsync,
AddAsync, SaveChangesAsync.
* SnapshotRepository drops the broken Get/Update/Delete methods.
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).
* 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.
Implements Phase 8 Amendment 1: marathonbet.by has no public results archive
endpoint, so results must be harvested per-event by re-fetching the event
detail page until eventJsonInfo.matchIsComplete=true.
Backend changes:
* IOddsScraper:
- ScrapeResultsAsync(DateRange) replaced with ScrapeEventResultAsync(Event)
returning a nullable EventResult — null when match still in progress.
- ScrapeEventOddsAsync now takes the full Event (so EventPath drives URL
construction) instead of bare EventId.
- New ScrapeLiveAsync() for the /su/live listing.
* Domain:
- Event gains EventPath (nullable string) — the data-event-path attribute
captured during scraping; required for reliable URL construction.
* Infrastructure:
- New migration 20260506000000_AddEventPath adds the column.
- EventEntity / EventConfiguration / Mapping / model-snapshot updated.
- MarathonbetScraper: new ScrapeLiveAsync + ScrapeEventResultAsync; URL
builder prefers EventPath, falls back to numeric ID for legacy rows.
- EventListingParserBase extracts data-event-path on every listing row.
* Application:
- PullResultsUseCase: branches on selection vs date-range, emits IProgress<
PullResultsProgress>, returns ResultLoadOutcome (Loaded / AlreadyLoaded /
NotYetComplete / Failed); idempotent (skips events whose result already
exists).
- PullLiveOddsUseCase now drives off the live listing (auto-discovers
events that go live without ever appearing in the upcoming list) and
backfills EventPath on legacy rows.
- PullUpcomingEventsUseCase wires EventPath on persisted events.
* Workers: UpcomingEventsPoller updates persistence path accordingly.
* Tests: 17 net-new tests across Application + Infrastructure + Domain;
all 293 still pass.
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).
Phase 7 reviewer (Sonnet, combined backend + frontend) flagged 3 🟡 warnings;
two real fixes here, one tracking:
W1 — DetectAnomaliesUseCase had an undocumented N+1: _anomalyRepo.ListAsync
was called inside ProcessEventAsync, once per event. Hoisted to ExecuteAsync
before the loop and threaded into ProcessEventAsync as a parameter. The
per-event slice happens in-memory now. O(N_events) DB round-trips → 1.
W2 — AnomalyDetector.ExtractMatchWinProbabilities had a dead expression
'(decimal?)null ?? 0m' that always evaluated to 0m. Simplified to
'drawBet is not null ? rawDraw / total : 0m'. The 0m is never surfaced
anyway (PDraw in the return uses the same null guard), so behaviour is
identical.
W3 — PLAN.md row updated with both Phase 7 commit hashes (a6ff368 backend
+ 12208a4 frontend) and review verdict.
Build 0/0, 276 tests still passing.
- AnomalyDetector (pure domain): detects odds-flip pattern from live snapshot
timelines using implied-probability vectors (p=1/rate, normalised), flip score
= max(|p_post−p_pre|), gated by both threshold AND favourite-changed test
- SuspensionInterval record: typed pair of (pre, post) OddsSnapshot bracketing a gap
- AnomalyOptions POCO (Application layer): bound to Anomaly:* config section with
four fields (SuspensionGapSeconds=60, OddsFlipThreshold=0.30, MinSnapshotCount=3,
DetectionIntervalSeconds=60)
- DetectAnomaliesUseCase: iterates all events, loads last-24h live snapshots, runs
detector, persists new anomalies with 1-minute dedup window
- AnomalyDetectionPoller: BackgroundService polling every DetectionIntervalSeconds,
gated by WorkerOptions.AnomalyDetectionEnabled (default true)
- DI wiring: DetectAnomaliesUseCase registered Scoped in ApplicationModule;
AnomalyOptions bound + AnomalyDetectionPoller hosted in InfrastructureModule
- WorkerOptions.AnomalyDetectionEnabled added; appsettings.json updated
- 13 domain tests + 4 application tests; total 245/245 passing (no regression)
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.
Combined-batch reviewer flagged three real blockers + two test-infra
issues across the parallel P2/P3/P5 batch. All resolved:
PHASE 3 — DateTimeOffset UTC-kind constructor (3 sites)
EventListingParserBase.cs:39, EventOddsParser.cs:72, ResultsParser.cs:104
Replaced `new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset)`
(throws ArgumentException because UtcDateTime has Kind=Utc) with
`DateTimeOffset.UtcNow.ToOffset(MoscowOffset)`.
PHASE 2 — EF string.Compare not translatable (3 sites)
EventRepository.cs:34, SnapshotRepository.cs:46, ExcelExporter.cs:35
Replaced `string.Compare(col, str, StringComparison.Ordinal)` with
`col.CompareTo(str)` so EF Core's SQLite provider can translate the
expression. Semantics unchanged (SQLite default collation = BINARY = ordinal).
PHASE 3 — ServerTimeProvider regex misses JSON-quoted key
Regex `serverTime\s*:\s*"..."` only matched bare-key form. Updated to
`"?serverTime"?\s*:\s*"..."` so the JSON-quoted form (the actual
marathonbet.by production format) is matched.
PHASE 3 — fixture: orphan <td> elements stripped by HTML5 parser
tests/.../Fixtures/marathonbet/event-football-sample.html — wrapped
the <td> blocks in a proper <table><tbody><tr> hierarchy so AngleSharp
preserves them and `td.Closest("td")` succeeds in the parser.
PHASE 2 — InMemoryDbFixture shared state across parallel tests
All fixture instances used `Data Source=marathon_tests` causing xUnit's
parallel-within-class runs to contaminate each other's data. Each fixture
now uses a Guid-suffixed unique data source name.
PLAN.md — P2/P3/P5 rows updated to ✅ Done with batch commit reference.
Test status:
Domain.Tests: 96/96 ✅
Application.Tests: 1/1 ✅
Infrastructure.Tests: 77/77 ✅
UI.Tests: 11/11 ✅
TOTAL: 185/185 ✅
Build: 0 warnings, 0 errors.
Deferred to later phases (per reviewer 🟡 / 🔵 notes):
- SnapshotRepository.GetAsync(Guid) uses lossy GetHashCode workaround;
Phase 4 to fix or remove from interface.
- Excel Sport name column writes string.Empty (need lookup join in Phase 6).
- PeriodScopeMapper football n>2 falls through to "Quarter" token;
guarded by MaxPeriods today, but defensive cleanup at Phase 9.
- Settings.razor duplicate m-rise-5 class on Localization section.
Three minimal fixes to make Marathon.sln build with 0/0:
1. Marathon.Infrastructure.csproj — add InternalsVisibleTo for
Marathon.Infrastructure.Tests so test code can reference internal
repository and exporter classes (Phase 2 issue blocking Phase 3 tests).
2. EventOddsParserTests.cs — add 'using Marathon.Domain.ValueObjects' so
MatchScope/PeriodScope resolve.
3. RoundTripTests.cs — add 'using Microsoft.EntityFrameworkCore' so the
ExecuteSqlRawAsync extension method on DatabaseFacade resolves.
Phase 5's anticipated LocalizationOptions / Serilog issues were already
resolved by its agent before being killed — no changes needed there.
Build status: 0 warnings, 0 errors.
Test status: Domain 96/96, UI 11/11, Infrastructure 42/77 (35 failing —
parser fixture issues + a real DateTimeOffset bug; reviewer will assess).
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).
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.
Anonymous scraping confirmed feasible for marathonbet.by — site is fully SSR
(nginx), no Cloudflare or JS challenge. HttpClient + AngleSharp + Polly v8 is
sufficient; Playwright not required (kept as a future-flag).
Spike outputs:
- spike/SCRAPE_FINDINGS.md — page rendering, URL templates, anti-bot, rate
limits, recommended scraping strategy for Phase 3.
- spike/SCHEMA_DRAFT.md — customer-spec field → DOM selector mapping for
Match + Period-N scope across football/basketball/tennis (hockey TBD).
Phase 1+ handoff captured in subplan + CLAUDE.md. Critical Phase 8 finding:
no public results endpoint at /su/results — phase 8 must switch to polling
event-detail until eventJsonInfo.matchIsComplete=true (deviation flagged).
Reviewer notes addressed:
- Period market outcome codes corrected to RN_H/RN_D/RN_A (not 1/draw/3) and
market name vocabulary clarified per-sport in SCHEMA_DRAFT §3.1.
- results-page.html capture added to file list with caveat about live-landing
score-state and unsampled hockey selectors.