# CLAUDE.md — maraphon-app > Project memory for Claude Code sessions on this repository. Keep entries concise. > Per-feature learnings are appended below by the feature-planner workflow. ## Project Overview **maraphon-app** is a sports betting odds analyzer for marathonbet.by. It scrapes pre-match (`/su`) and live (`/su/live`) events, persists odds snapshots over time, and detects anomalies — especially the **odds-flip** pattern (bookmaker freezes bets then inverts underdog/favorite coefficients). ## Architecture (Clean Architecture, 5 projects + tests) ``` Marathon.Domain ← entities, value objects, no external deps ↑ Marathon.Application ← use cases + abstractions (IOddsScraper, IRepository, ...) ↑ Marathon.Infrastructure ← EF Core (SQLite), scraping (AngleSharp/Playwright), Excel, Polly Marathon.UI ← Razor Class Library (all Blazor components — host-agnostic) ↑ Marathon.Hosts.WpfBlazor ← WPF + BlazorWebView host (replaceable for ASP.NET Core later) ``` **Key portability invariant:** All UI lives in `Marathon.UI` (Razor Class Library). The host project (`Marathon.Hosts.WpfBlazor`) is the *only* thing that changes when migrating to a web app — drop in an ASP.NET Core Blazor Server host that references the same RCL. ## Tech stack - **.NET 8 LTS**, C# 12 - **EF Core 8** + SQLite (WAL mode) - **AngleSharp** (HTML), **Playwright for .NET** (SPA fallback) - **Polly v8** (`Microsoft.Extensions.Http.Resilience`) - **MudBlazor** components, **Plotly.Blazor** charts - **Serilog** logging (rolling file + console) - **xUnit + FluentAssertions + NSubstitute**, in-memory SQLite for repo tests ## Build & test | Command | Purpose | |---|---| | `dotnet build Marathon.sln` | Build all projects | | `dotnet test Marathon.sln` | Run all tests | | `dotnet format Marathon.sln --verify-no-changes` | Lint | | `dotnet run --project src/Marathon.Hosts.WpfBlazor` | Run desktop app | ## Coding conventions - Nullable reference types: **enabled** (`enable`) - Implicit usings: enabled - Treat warnings as errors in `Release` builds - File-scoped namespaces - One public type per file (except small DTOs/records grouped in a feature folder) - Domain entities: prefer `record` for immutable data; class with private setters when identity matters - No mutation of domain objects after construction — return new instances - Repositories return `IReadOnlyList`, not `List` or `IEnumerable` (clarity on enumeration cost) - Tests follow `Given_When_Then` or `Should__When_` naming ## Configuration Every variable parameter is configurable via `appsettings.json` and overridable via `appsettings.Local.json` (gitignored) or environment variables: - `Scraping:PollingIntervalSeconds` (default 30) - `Scraping:MaxConcurrentRequests` (default 4) - `Scraping:UserAgents[]` (rotated per request) - `Scraping:RetryPolicy:*` (Polly settings) - `Scraping:RateLimit:RequestsPerSecond` (default 1) - `Storage:DatabasePath` (default `./data/marathon.db`) - `Storage:ExportDirectory` (default `./exports`) - `Storage:SnapshotRetentionDays` (default 90) - `Anomaly:SuspensionGapSeconds` (default 60) - `Anomaly:OddsFlipThreshold` (default 0.30 — implied probability delta) - `Localization:DefaultCulture` (default `ru-RU`) A future Settings page in the UI binds to these. ## Domain model summary - `Sport(Code, Name)` — e.g., `(6, "Баскетбол")` - `Event(Id, SportCode, CountryCode, LeagueId, CategoryId, ScheduledAt, EventCodeFromBookmaker)` - `OddsSnapshot(EventId, CapturedAt, Source: Pre|Live, Bets: List)` - `Bet(Scope: Match|Period[N], Type: Win|Draw|WinFora|Total, Side: 1|2|Less|More, Value?, Rate)` - `EventResult(EventId, FinalScore, WinnerSide)` - `Anomaly(EventId, DetectedAt, Kind: SuspensionFlip, Score, EvidenceTimeline)` ## Excel export schema (compliance with customer spec) Customer TZ requires wide-table layout with columns like `Bet_Match_Win_1`, `Bet_Period-1_Win_Fora_2_Value`, etc. **Internal storage is normalized** (one row per Bet in `OddsSnapshots`). The Excel exporter denormalizes to the wide format on demand. Filename pattern: ``` Marathon__to_.xlsx ``` ## Recurring Issues & Patterns - **`dotnet new sln` on .NET 10 SDK produces `.slnx`**, not `.sln`. If the plan references `Marathon.sln`, hand-craft the traditional format alongside `.slnx`. - **`Marathon.Application` namespace vs `System.Windows.Application`:** in any WPF project that references `Marathon.Application`, always write `System.Windows.Application` fully qualified in `App.xaml.cs`. - **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`). - **Razor source generator does NOT accept C# 11 raw string literals** (`"""…"""`) inside `@code` blocks — concatenate single-quoted attribute strings instead. - **Razor reserves the identifier `code`.** Loop variables must use any other name (`var sportCode in ...`) or the parser treats it as the `@code` directive. - **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`** in any Razor file that pulls both namespaces via `_Imports.razor`. Use a per-file alias: `using AppDateRange = Marathon.Application.Storage.DateRange;`. - **`Plotly.Blazor.LayoutLib.Margin` clashes with `MudBlazor.Margin`.** Fully qualify the Plotly side at the new-expression: `new Plotly.Blazor.LayoutLib.Margin {…}`. - **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`, never pass a `DateTime.UtcNow` value to that constructor. - **EF migrations — generate with `dotnet ef`, do NOT `migrations remove`.** `MarathonDbContextFactory` is a self-contained design-time factory, so `dotnet ef migrations add X --project src/Marathon.Infrastructure --startup-project src/Marathon.Infrastructure` works. AVOID `dotnet ef migrations remove`: older migrations were hand-written without a Designer snapshot, so remove blanks `MarathonDbContextModelSnapshot.cs` and the next `add` regenerates the WHOLE schema. Validate a migration on a throwaway DB with `dotnet ef database update --connection "Data Source=/data/_migtest.db"` (absolute path — EF resolves relatives from the build-output dir, not the shell cwd; create `./data/` first). - **`Sports.Code` is `.ValueGeneratedNever()`** — it's the bookmaker's natural sport id (6/11/22723…), not an autoincrement surrogate. Without it EF's int-PK convention emits a spurious AUTOINCREMENT `AlterColumn` on every migration. - **Validated get-only record properties block `with`** (CS0200): `BacktestStrategy.MinScore`, `PaperBet.Rate`/`Stake` are re-declared with validation, so build a new instance instead of `with { ThatProp = … }` (you can still `with` the un-redeclared props, e.g. `SavedStrategy with { Strategy = … }`). - **`JsonSettingsWriter.SaveSectionAsync` replaces the whole section** (`root[section]=json`), so a Settings-UI save drops any key not on the form. Never surface a secret-bearing section (e.g. `Notifications` → Telegram token) in the UI — it would wipe the secret from `appsettings.Local.json`. To surface a non-secret section, add a mutable mirror options class in `Marathon.UI.Services` bound to the same section name (UI can't reference Infrastructure's options types — same pattern as the two `WorkerOptions`). ## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings - **Two `WorkerOptions` classes coexist** with the same JSON shape but different namespaces: `Marathon.Infrastructure.Configuration.WorkerOptions` (immutable `init`, used by workers) and `Marathon.UI.Services.WorkerOptions` (mutable `set`, used by Settings page). Both bind to `"Workers"` in `appsettings.json`. Keep them in sync when adding new keys. - **`Microsoft.Extensions.Logging.EventId` conflicts with `Marathon.Domain.ValueObjects.EventId`** in any project that adds `Microsoft.Extensions.Logging.Abstractions`. Fix with a global alias in `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;` and local file aliases where both are used together. - **NSubstitute cannot proxy `sealed` classes.** Use cases are `sealed record` or `sealed class`. Worker tests must build a real use-case instance backed by substituted interfaces rather than substituting the use case directly. - **`BackgroundService` workers are singletons; use cases are scoped.** Always resolve scoped use cases via `IServiceProvider.CreateAsyncScope()` inside the worker loop — never inject them directly into the constructor. - **Cronos 6-field cron format.** Pass `CronFormat.IncludeSeconds` to `CronExpression.Parse` when the expression has a seconds field (e.g., `"0 0 */6 * * *"`). Default Cronos parse expects 5-field (no seconds). - **`ApplicationModule.AddMarathonApplication` takes no `IConfiguration`** — the Application layer has no config bindings of its own. Infrastructure and UI bind their own options sections. ## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings (Permanent learnings about marathonbet.by data shape, anti-bot, page structure. For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.) - **Site is fully SSR (`Server: nginx`).** Anonymous GET with browser User-Agent returns full HTML for `/su/`, `/su/live`, `/su/popular/`, `/su/betting/`. No Cloudflare, no JS challenge. - **Use HttpClient + AngleSharp + Polly v8** — no Playwright needed for read-only. Keep `Scraping:UsePlaywright = false` flag for future-proofing. - **Sport ID = `data-sport-treeId` = breadcrumb canonical ID.** Confirmed: Basketball=6, Football=11, Tennis=22723, Hockey=43658. URL by ID: `/su/betting/+-+` (preferred over `/su/popular/` because the ID is stable). - **`EventCode` = `data-event-eventId`** (numeric, ~26-million range, stable). `TreeId` = `data-event-treeId` (URL-routing ID, less stable). Use `EventCode` as the entity primary key in SQLite. - **Selection key format:** `{eventId}@{MarketName}{LineIndex?}.{Outcome}`. Outcomes: `1`/`draw`/`3` for 3-way, `HB_H`/`HB_A` for handicap, `Under_`/ `Over_` for totals. Total threshold is encoded in the outcome string; handicap value lives in `` text. - **Tennis has no Draw outcome.** Domain `Bet_Match_Draw` must be nullable; Excel exporter writes empty cell when null. - **Date parsing:** listing shows `HH:MM` (today) or `DD HH:MM` (future). Anchor with `initData.serverTime` (Moscow TZ, format `YYYY,MM,DD,HH,MM,SS`) parsed from the embedded `