Files
maraphon-app/CLAUDE.md
T
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

11 KiB
Raw Blame History

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 (<Nullable>enable</Nullable>)
  • 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<T>, not List<T> or IEnumerable<T> (clarity on enumeration cost)
  • Tests follow Given_When_Then or Should_<expected>_When_<condition> 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>)
  • 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_<YYYY-MM-DD>_to_<YYYY-MM-DD>.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.

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/<Sport>, /su/betting/<event-path>. 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/<Sport>+-+<id> (preferred over /su/popular/<Sport> 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_<X>/ Over_<X> for totals. Total threshold is encoded in the outcome string; handicap value lives in <span class="middle-simple"> 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 <ru-month> HH:MM (future). Anchor with initData.serverTime (Moscow TZ, format YYYY,MM,DD,HH,MM,SS) parsed from the embedded <script> blob on every scraped page.
  • Live updates: site polls /su/liveupdate/popular/?treeIds=... every 3 s but response is just {"modified":[{"type":"refreshPage"}],...} — re-scrape the full event detail HTML for actual odds. Our analyzer cadence: pre-match 30 s, live 510 s.
  • No public results / archive page (/su/results → 404). Final scores must be harvested by polling the event detail page until eventJsonInfo.matchIsComplete=true, then storing resultDescription. Phase 8 cannot back-fill from a public archive.
  • Period scope vocabulary varies by sport: football=1st_Half, basketball= 1st_Half/1st_Quarter, tennis=1st_Set, hockey=1st_Period. Domain stores PeriodNumber:int and a sport-aware PeriodScopeMapper resolves the correct market token at parse time.

Feature: Initial Implementation > Phase 7: Anomaly UI — Learnings

  • Severity buckets are a single-source rule in Marathon.UI.Services.AnomalySeverityRules.FromScore (Low <0.45, Medium <0.60, High ≥0.60). The badge pill, card border, feed filter chip, and stat strip all bind through it — never duplicate the thresholds.
  • AnomalyBrowsingService is Scoped, AnomalyBrowsingState is Singleton — same lifetime split as EventBrowsingService / EventBrowsingState. State holds the immutable AnomalyFilter + LastSeenUtc + cached unread count.
  • Anomaly.EvidenceJson is parsed once in the service layer via JsonSerializer.Deserialize<EvidenceDto>(json, PropertyNameCaseInsensitive=true) with private nested DTOs. Pages bind to AnomalyEvidenceSnapshot value-records — they never see the raw JSON. Malformed JSON is dropped silently from the feed.
  • 2-way markets (tennis) carry pDraw=null / rateDraw=null. The AnomalyEvidence and AnomalyCard components key off the IsTwoWay flag on AnomalyListItem (computed when both pre/post pDraw are null) to omit the Draw row in BOTH columns — never per-column-individually.
  • Signal-red is the load-bearing alert tone for Phase 7. Use var(--m-c-anomaly) exclusively (never raw #dc2626). Pulsing animation (m-pulse) MUST respect prefers-reduced-motion.