From 517c6b987b8fecc48bac887e57e788de1d84e083 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 00:45:46 -0400 Subject: [PATCH] Add local-first refactor foundation --- .gitignore | 1 + cmd/main.go | 60 ++ documentations/refactor/01-data-sources.md | 179 ++++ .../refactor/02-backend-architecture.md | 216 ++++ documentations/refactor/03-database-design.md | 195 ++++ documentations/refactor/04-web-ui-product.md | 231 +++++ documentations/refactor/05-frontend-stack.md | 152 +++ .../refactor/06-visual-design-direction.md | 123 +++ .../refactor/07-research-agents-brief.md | 206 ++++ .../refactor/08-v1-scope-and-phasing.md | 176 ++++ .../refactor/09-phase-1-live-extraction.md | 195 ++++ .../refactor/10-phase-2-store-foundation.md | 136 +++ .../11-phase-3-ingestion-foundation.md | 163 +++ .../12-phase-4-local-first-web-api.md | 139 +++ documentations/refactor/README.md | 86 ++ ...rsor-phase-4-local-first-web-api-prompt.md | 110 ++ .../refactor/screens/command-center.html | 683 +++++++++++++ .../refactor/screens/component-notes.md | 375 +++++++ .../refactor/screens/data-library.html | 651 ++++++++++++ documentations/refactor/screens/index.html | 180 ++++ .../refactor/screens/live-timing.html | 774 +++++++++++++++ .../refactor/screens/mobile-live.html | 939 ++++++++++++++++++ documentations/refactor/screens/race-hub.html | 847 ++++++++++++++++ documentations/refactor/screens/styles.css | 779 +++++++++++++++ internal/api/client.go | 5 + internal/api/openf1.go | 47 + internal/ingest/ingest.go | 471 +++++++++ internal/ingest/ingest_test.go | 381 +++++++ internal/ingest/openf1.go | 270 +++++ internal/ingest/progress.go | 49 + internal/live/parser_test.go | 323 ++++++ internal/live/signalr.go | 82 ++ internal/live/state.go | 495 +++++++++ internal/live/types.go | 144 +++ internal/store/db.go | 92 ++ internal/store/meetings.go | 295 ++++++ internal/store/migrations.go | 92 ++ internal/store/migrations/001_initial.sql | 118 +++ internal/store/models.go | 106 ++ internal/store/raw.go | 177 ++++ internal/store/results.go | 302 ++++++ internal/store/runs.go | 31 + internal/store/store_test.go | 450 +++++++++ internal/ui/official_live.go | 706 +------------ internal/web/live.go | 10 +- 45 files changed, 11545 insertions(+), 697 deletions(-) create mode 100644 documentations/refactor/01-data-sources.md create mode 100644 documentations/refactor/02-backend-architecture.md create mode 100644 documentations/refactor/03-database-design.md create mode 100644 documentations/refactor/04-web-ui-product.md create mode 100644 documentations/refactor/05-frontend-stack.md create mode 100644 documentations/refactor/06-visual-design-direction.md create mode 100644 documentations/refactor/07-research-agents-brief.md create mode 100644 documentations/refactor/08-v1-scope-and-phasing.md create mode 100644 documentations/refactor/09-phase-1-live-extraction.md create mode 100644 documentations/refactor/10-phase-2-store-foundation.md create mode 100644 documentations/refactor/11-phase-3-ingestion-foundation.md create mode 100644 documentations/refactor/12-phase-4-local-first-web-api.md create mode 100644 documentations/refactor/README.md create mode 100644 documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md create mode 100644 documentations/refactor/screens/command-center.html create mode 100644 documentations/refactor/screens/component-notes.md create mode 100644 documentations/refactor/screens/data-library.html create mode 100644 documentations/refactor/screens/index.html create mode 100644 documentations/refactor/screens/live-timing.html create mode 100644 documentations/refactor/screens/mobile-live.html create mode 100644 documentations/refactor/screens/race-hub.html create mode 100644 documentations/refactor/screens/styles.css create mode 100644 internal/ingest/ingest.go create mode 100644 internal/ingest/ingest_test.go create mode 100644 internal/ingest/openf1.go create mode 100644 internal/ingest/progress.go create mode 100644 internal/live/parser_test.go create mode 100644 internal/live/signalr.go create mode 100644 internal/live/state.go create mode 100644 internal/live/types.go create mode 100644 internal/store/db.go create mode 100644 internal/store/meetings.go create mode 100644 internal/store/migrations.go create mode 100644 internal/store/migrations/001_initial.sql create mode 100644 internal/store/models.go create mode 100644 internal/store/raw.go create mode 100644 internal/store/results.go create mode 100644 internal/store/runs.go create mode 100644 internal/store/store_test.go diff --git a/.gitignore b/.gitignore index bc90bda..640c889 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ # Log files *.log +.DS_Store # SQLite database files *.db diff --git a/cmd/main.go b/cmd/main.go index 38a119f..0631959 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,6 +8,8 @@ import ( "time" "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/ingest" + "github.com/AmanTahiliani/box-box/internal/store" "github.com/AmanTahiliani/box-box/internal/ui" "github.com/AmanTahiliani/box-box/internal/web" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +18,11 @@ import ( func main() { webMode := flag.Bool("web", false, "Start web companion server instead of TUI") port := flag.Int("port", 8080, "Port for web server (used with --web)") + ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year") + ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest OpenF1 sessions for a meeting key") + ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key") + dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows") + dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)") flag.Parse() var client *api.OpenF1Client @@ -29,6 +36,28 @@ func main() { // Clean up old file-based cache (one-time migration). go api.CleanupOldFileCache() + ingestFlags := 0 + if *ingestYear != 0 { + ingestFlags++ + } + if *ingestMeeting != 0 { + ingestFlags++ + } + if *ingestSession != 0 { + ingestFlags++ + } + if ingestFlags > 0 { + if ingestFlags > 1 { + fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --ingest-meeting, or --ingest-session may be set") + os.Exit(1) + } + if err := runIngestion(client, *ingestYear, *ingestMeeting, *ingestSession, *dryRun, *dbPath); err != nil { + fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err) + os.Exit(1) + } + return + } + if *webMode { log.SetOutput(os.Stderr) // web mode logs to stderr, not file fmt.Printf("box-box web → http://localhost:%d\n", *port) @@ -56,3 +85,34 @@ func main() { os.Exit(1) } } + +func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, dryRun bool, dbPath string) error { + log.SetOutput(os.Stderr) + + path := dbPath + if path == "" { + path = store.DefaultDBPath() + } + + st, err := store.Open(path) + if err != nil { + return fmt.Errorf("open domain database: %w", err) + } + defer st.Close() + + opts := ingest.DefaultOptions() + opts.DryRun = dryRun + opts.Progress = ingest.NewProgress(os.Stderr) + + svc := ingest.NewService(st, ingest.NewOpenF1Source(client), opts) + + switch { + case year != 0: + _, err = svc.IngestYear(year) + case meetingKey != 0: + _, err = svc.IngestMeeting(meetingKey) + case sessionKey != 0: + _, err = svc.IngestSession(sessionKey) + } + return err +} diff --git a/documentations/refactor/01-data-sources.md b/documentations/refactor/01-data-sources.md new file mode 100644 index 0000000..88084b7 --- /dev/null +++ b/documentations/refactor/01-data-sources.md @@ -0,0 +1,179 @@ +# Data Sources + +## Summary + +`box-box` should treat data sources as inputs to a local product database, not as +page-level dependencies. The current app fetches too much data on demand from +OpenF1, which breaks down during free-tier lockouts and makes non-live screens +feel empty. The live mode succeeds because it uses the official F1 live timing +feed directly. + +## Confirmed Sources + +### OpenF1 REST API + +Reference: https://openf1.org/docs/ + +Current usage: + +- Meetings and sessions. +- Drivers. +- Championship standings. +- Session results and starting grid. +- Laps, stints, pit stops, positions, intervals. +- Race control, weather, overtakes. +- Car data, location, team radio metadata. + +Strengths: + +- Good historical/session data source. +- JSON over simple HTTP. +- Broad endpoint coverage. +- Query filtering by fields and time ranges. + +Limitations: + +- Free-tier access can be locked during live sessions. +- On-demand fetching is unreliable as a product behavior. +- API schema or access rules can change. +- High-volume endpoints can be expensive to fetch repeatedly. + +Policy: + +- Use OpenF1 primarily for ingestion and backfill. +- Do not make historical Web pages depend on fresh OpenF1 calls. +- Store successful fetches into the local domain database and raw payload log. + +### Official F1 SignalR Live Feed + +Endpoint: https://livetiming.formula1.com/signalr + +Current code connects to the old ASP.NET SignalR protocol, negotiates a +connection token, opens a websocket, and subscribes to the `Streaming` hub. + +Current subscribed topics: + +- `Heartbeat` +- `TimingData` +- `DriverList` +- `LapCount` +- `ExtrapolatedClock` +- `TrackStatus` +- `RaceControlMessages` +- `WeatherData` +- `SessionInfo` +- `CurrentTyres` +- `TimingAppData` +- `TimingStats` + +Strengths: + +- Best current source for live timing. +- Provides race-control updates quickly. +- Avoids OpenF1 REST lockout during active sessions. +- Powers the strongest part of the existing app. + +Limitations: + +- Payloads are less formally documented than OpenF1. +- Topic schemas can drift. +- Testing live behavior is difficult outside active sessions. +- Current parser lives in `internal/ui`, which couples live source handling to + the TUI layer. + +Policy: + +- Treat SignalR as the authoritative live source while a session is active. +- Extract parsing and live-state logic into reusable backend/domain code. +- Forward live state to the Web UI through SSE initially. +- Research whether live snapshots/events should be persisted. + +### Existing SQLite HTTP Cache + +Current location: user cache directory under `box-box/cache.db`. + +Current behavior: + +- Stores raw HTTP responses by URL. +- Applies TTL rules based on URL patterns. +- Can return stale responses when OpenF1 fails. +- Stores track outlines in a structured table. + +Strengths: + +- Useful as a fallback. +- Already integrated with the OpenF1 client. +- Reduces repeated network calls. + +Limitations: + +- Not a queryable domain model. +- URL keys are poor product identifiers. +- Cannot easily power analytics, replay, ingestion status, or data provenance. +- Pruning/TTL behavior is cache-oriented, not history-oriented. + +Policy: + +- Keep the raw cache as a fallback layer. +- Do not use it as the primary application database. +- Add a separate domain schema for product features. + +## Candidate Source + +### Official F1 Static Archived Timing Files + +Reference: +https://livef1.goktugocal.com/livetimingf1/data_topics.html + +Examples in public references include: + +- `SessionInfo.json` +- `ArchiveStatus.json` +- `TrackStatus.jsonStream` +- `SessionData.json` +- `TyreStintSeries.json` +- `SessionStatus.json` +- `TimingDataF1.json` + +Potential strengths: + +- Could provide replay-quality archived live timing. +- May fill gaps between OpenF1 REST data and SignalR live data. +- May support historical race reconstruction. + +Known uncertainties: + +- Session path mapping must be researched. +- Stability and access guarantees are unclear. +- Topic schemas and file availability may vary by year/session. +- Legal and operational usage expectations need review. + +Policy for now: + +- Do not make core architecture depend on this source yet. +- Assign a dedicated research track to validate feasibility. +- If adopted, ingest it through the same raw-plus-normalized source pipeline. + +## Source Authority Tiers + +1. Local SQLite domain database. + - Primary read source for Web UI historical and completed-session data. +2. Official F1 SignalR live feed. + - Primary source during active sessions. +3. OpenF1 REST ingestion/backfill. + - Primary source for populating local historical data. +4. Optional F1 static archive source. + - Research candidate for richer replay and archived live timing. +5. Raw HTTP cache fallback. + - Last-resort resilience layer, not a product data model. + +## Open Questions + +- Should SignalR snapshots/events be persisted during live sessions? +- If persisted, should live data become the authoritative record for that + session or a supplemental event stream? +- Which OpenF1 endpoints are essential for v1 local-first Race Hub? +- Can static archived timing files be mapped reliably from OpenF1 sessions? +- What data should be refreshed after a session ends, and when should it become + immutable? + diff --git a/documentations/refactor/02-backend-architecture.md b/documentations/refactor/02-backend-architecture.md new file mode 100644 index 0000000..cfa390d --- /dev/null +++ b/documentations/refactor/02-backend-architecture.md @@ -0,0 +1,216 @@ +# Backend Architecture + +## Summary + +The backend should move from direct page handlers calling OpenF1 into a layered +local-first architecture. Source clients fetch data, ingestion persists it, +store/query packages expose domain reads, and Web handlers return read models +with source and freshness metadata. + +## Proposed Package Boundaries + +### `internal/store` + +Owns SQLite as the local domain database. + +Responsibilities: + +- Schema creation and migrations. +- Typed upsert methods for domain records. +- Typed read methods for screens and backend services. +- Raw payload storage. +- Ingestion metadata and provenance. +- Transactions and batch writes. + +Non-goals: + +- Calling OpenF1 directly. +- Knowing Web UI route behavior. +- Rendering derived frontend-specific structures unless they are shared read + models. + +### `internal/ingest` + +Coordinates backfill, refresh, and opportunistic fetches. + +Responsibilities: + +- Ingest year, meeting, or session. +- Fetch required endpoints through source clients. +- Persist raw payloads and normalized rows. +- Track partial successes and failures. +- Support resumable, idempotent runs. +- Respect rate limits and free-tier constraints. + +Default ingestion modes: + +- CLI bulk ingestion for years, meetings, and sessions. +- Opportunistic small fetches in Web mode when a user opens missing data. +- Explicit refresh mode for completed data when needed. + +Rate-limit defaults: + +- Bulk ingestion must be resumable and idempotent. +- Bulk ingestion should default to conservative sequential fetching with a + delay between OpenF1 requests. +- Failed requests should use bounded exponential backoff with jitter. +- HTTP 429 and live-session lockout should pause or stop the current run rather + than tight-loop retries. +- `--dry-run` should show planned datasets and estimated request count before a + large ingest. + +### OpenF1 Source Client Layer + +The current `internal/api` client can remain, but it should become one source +adapter rather than the main application data layer. + +Responsibilities: + +- Build OpenF1 URLs. +- Apply auth headers when `OPENF1_API_KEY` exists. +- Decode source payloads into source/domain structs. +- Preserve stale fallback behavior where useful. + +Future direction: + +- Make source fetches observable by ingestion metadata. +- Avoid direct UI route dependency on source calls. + +### Live Timing Bridge + +The current live parser should be extracted out of `internal/ui` into reusable +backend/domain logic. + +Responsibilities: + +- Connect to official F1 SignalR. +- Parse topic payloads into typed live events/state. +- Maintain current live snapshot. +- Broadcast snapshots to Web clients through SSE. +- Feed TUI live mode without coupling parser code to Bubble Tea. +- Persist live events/snapshots as an append-only stream once the bridge is + extracted. + +Persistence policy: + +- Live SignalR data should be stored separately from normalized post-session + OpenF1 records. +- Live data represents what was broadcast at the time, not necessarily the + corrected final historical record. +- A later reconciliation step can compare live stream data with OpenF1 + post-session records. + +### Web API Read Models + +Web handlers should become thin adapters from query services to JSON. + +Responsibilities: + +- Validate route/query parameters. +- Call local-first query/read services. +- Return consistent response envelopes. +- Include source/freshness metadata. + +Suggested response metadata: + +- `source`: `local`, `api`, `cache`, `live`, or `missing`. +- `last_ingested_at`. +- `is_stale`. +- `missing_datasets`. +- `errors` where partial data is returned. + +### CLI Ingestion Commands + +CLI commands should make bulk ingestion explicit and user-controlled. + +Candidate commands/flags: + +- `--ingest-year 2024` +- `--ingest-meeting ` +- `--ingest-session ` +- `--refresh` +- `--dry-run` + +CLI output should include: + +- What will be fetched. +- What is already local. +- What succeeded. +- What failed. +- Whether the run is resumable. + +## Local-First Read Behavior + +Default rule: + +1. Read from local domain DB. +2. If missing and request scope is small, optionally fetch from OpenF1. +3. Persist successful fetches. +4. Return local/read-model data with metadata. +5. If OpenF1 is unavailable, return partial local data and clear missing/stale + metadata rather than an empty page. + +Examples: + +- Opening a completed race with all local data should perform no OpenF1 calls. +- Opening a completed race with missing weather may opportunistically fetch only + weather. +- Opening a whole season should not silently trigger a large backfill. +- During live-session lockout, historical pages should still render from local + data. + +## Opportunistic Fetch Policy + +Allowed by default: + +- Single meeting sessions. +- Single session results/grid/weather/race control. +- Small metadata gaps needed to render a screen. + +Not allowed by default: + +- Full season backfills. +- High-volume telemetry/location/car data. +- Repeated refresh loops during API lockout. +- Silent destructive refresh of completed local data. + +## Migration Strategy + +The existing SQLite HTTP cache should remain operational during the refactor. +The new domain database should be introduced without requiring users to delete +their current cache. + +Default migration stance: + +- Keep the current cache tables and stale fallback behavior intact. +- Introduce domain tables through `internal/store`. +- Prefer a separate domain database file at first if it materially reduces + migration risk; using the same SQLite file remains acceptable if table names + and migrations are carefully isolated. +- Do not attempt to transform arbitrary URL-keyed cache entries into domain rows + automatically. +- New ingestion runs should populate domain tables from fresh source fetches or + explicitly supported raw payloads. +- Web routes can migrate endpoint by endpoint from source-first to local-first. + +## Failure Modes + +The backend should explicitly represent: + +- Local data available. +- Local data partial. +- Local data missing. +- OpenF1 locked/unavailable. +- Stale cache fallback used. +- Live feed connected/disconnected. +- Ingestion partial failure. + +The Web UI should be able to show these states without guesswork. + +## Open Questions + +- Should API response envelopes be introduced globally or per endpoint during + migration? +- How should source schema drift be detected and surfaced? +- What is the minimum dataset required for a Race Hub to be considered + complete? diff --git a/documentations/refactor/03-database-design.md b/documentations/refactor/03-database-design.md new file mode 100644 index 0000000..af50cff --- /dev/null +++ b/documentations/refactor/03-database-design.md @@ -0,0 +1,195 @@ +# Database Design + +## Summary + +SQLite should become the local source of truth for historical and completed +session data. The design should store both raw source payloads and normalized +domain rows. Raw payloads preserve source fidelity and make reprocessing +possible. Normalized rows power fast product queries, analytics, and stable Web +screens. + +## Storage Strategy + +Use two layers: + +1. Raw source storage. + - Preserve fetched payloads exactly enough to reprocess later. + - Track source, endpoint/topic, parameters, fetch time, status, and errors. +2. Normalized domain tables. + - Queryable application data keyed by F1 identifiers. + - Built from successful source payloads. + - Safe to upsert idempotently. + +## Raw Payload Tables + +Candidate tables: + +- `source_payloads` + - `id` + - `source` + - `resource` + - `request_key` + - `url_or_topic` + - `params_json` + - `payload_json` + - `fetched_at` + - `status` + - `error` + - `schema_version` + +- `ingestion_runs` + - `id` + - `scope_type` + - `scope_key` + - `started_at` + - `finished_at` + - `status` + - `refresh` + - `summary_json` + +- `ingestion_items` + - `id` + - `run_id` + - `dataset` + - `meeting_key` + - `session_key` + - `status` + - `source` + - `started_at` + - `finished_at` + - `error` + +## Normalized Domain Tables + +Core calendar/session tables: + +- `meetings` +- `sessions` +- `circuits` + +Participant tables: + +- `drivers` +- `session_drivers` +- `teams` or team snapshots by season/session. + +Classification and standings: + +- `session_results` +- `starting_grids` +- `driver_championship_standings` +- `constructor_championship_standings` + +Race/session analysis: + +- `laps` +- `stints` +- `pit_stops` +- `positions` +- `intervals` +- `race_control_messages` +- `weather_samples` +- `overtakes` + +Telemetry and spatial data: + +- `car_data_samples` +- `location_samples` +- `track_outlines` + +Media metadata: + +- `team_radio_messages` + +Derived/read-model candidates: + +- `session_dataset_status` +- `race_key_moments` +- `driver_session_summaries` +- `race_lap_snapshots` + +Derived tables should be added only when query cost or UI complexity justifies +them. Start with normalized source tables and build read models in Go unless +performance argues otherwise. + +## Provenance and Freshness + +Each normalized dataset should be traceable to source ingestion metadata. + +Track: + +- Source: OpenF1, SignalR, static archive, manual, cache. +- First ingested time. +- Last ingested time. +- Last successful refresh. +- Last error. +- Completion status. +- Whether stale fallback was used. + +This metadata supports the Data Library screen and makes partial data honest. + +## Immutability Policy + +Completed historical sessions: + +- Treat as immutable after successful ingestion. +- Do not refetch unless `--refresh` is explicitly requested. +- Allow reprocessing from raw payloads if schema or read models change. + +Current/future sessions: + +- Treat as refreshable. +- Allow opportunistic metadata fetches. +- Avoid high-volume refreshes without explicit action. + +Live sessions: + +- SignalR is authoritative for live state. +- Persist live data as an append-only event/snapshot stream after the live + bridge is extracted. +- Keep live data separate from normalized post-session OpenF1 records until + reconciliation is designed. +- Treat live data as the record of what was seen during the session, not as the + corrected final historical truth. + +## Migration And File Layout + +The current project already creates a SQLite cache database for raw HTTP +responses. The domain database should be introduced without breaking that cache. + +Default stance: + +- Existing cache tables are infrastructure, not product domain state. +- New domain tables should be owned by `internal/store`. +- A separate domain DB file is the lower-risk first implementation unless a + schema design pass shows strong reasons to reuse the same file. +- If the same file is reused, domain tables must be namespaced clearly and + migrations must avoid touching the current `cache` table except through + deliberate cache work. +- Do not auto-migrate URL-keyed cache entries into domain rows. +- Use explicit ingestion to populate the new domain tables. + +## High-Volume Data + +High-volume tables need careful indexing and retention decisions: + +- `car_data_samples` +- `location_samples` +- `positions` +- `intervals` +- `laps` for full-season analysis + +Initial policy: + +- Ingest high-volume telemetry only when explicitly requested. +- Keep Race Hub v1 focused on results, strategy, laps, race control, weather, + positions, and track outlines. + +## Research Questions + +- Exact indexes for Race Hub, Live Replay, Driver Explorer, and Standings. +- Whether `positions` and `intervals` should be downsampled or stored in full. +- Whether `car_data_samples` and `location_samples` should be optional datasets. +- How to map official F1 static archive sessions to OpenF1 `session_key`. +- Whether to use SQLite FTS for race-control/team-radio search. +- How to version schema migrations without adding unnecessary framework weight. diff --git a/documentations/refactor/04-web-ui-product.md b/documentations/refactor/04-web-ui-product.md new file mode 100644 index 0000000..b08c47c --- /dev/null +++ b/documentations/refactor/04-web-ui-product.md @@ -0,0 +1,231 @@ +# Web UI Product + +## Summary + +The Web UI should become the primary way to use `box-box`. The product should +feel like an F1 operations room: fast, dense when needed, precise, and native to +race-weekend workflows. It should work well on phone and iPad, while still +scaling into a richer desktop dashboard. + +## Product Priorities + +- Race-weekend first. +- Live Timing and Race Hub receive the highest polish. +- Historical pages should be local-first and reliable. +- Data availability should be visible, not mysterious. +- Density should be configurable. +- TUI live mode remains supported but does not require Web feature parity. + +## Core Screens + +### Command Center + +Default landing screen. + +Shows: + +- Current or upcoming race weekend. +- Next session countdown. +- Live session state. +- Weekend schedule. +- Weather snapshot. +- Championship context. +- Local data availability. +- Shortcuts into Live Timing, Weekend, Race Hub, Standings, and Data Library. + +### Season Calendar + +Year-based browsing screen. + +Shows: + +- All meetings for the selected year. +- Round, country, circuit, date range. +- Upcoming/live/completed state. +- Local ingestion status. +- Key outcomes after completion: winner, pole, fastest lap where available. +- Filters for missing data, completed races, sprint weekends, and upcoming + rounds. + +### Weekend Page + +One workspace per Grand Prix weekend. + +Shows: + +- Meeting metadata. +- Circuit and location. +- Session cards. +- Schedule and status. +- Dataset completeness. +- Entry points into each session view. + +### Race / Session Hub + +Main historical analysis workspace. + +For races, prioritize the strategy story: + +- Final classification. +- Starting grid and grid delta. +- Stint chart with compounds and pit stops. +- Safety car and VSC overlays. +- Position evolution. +- Lap-time comparison. +- Race-control timeline. +- Weather timeline. +- Driver race execution summaries. +- Replay scrubber with lap-by-lap standings and events. + +For practice and qualifying: + +- Classification. +- Best laps and sector breakdown. +- Lap progression. +- Driver comparison. +- Session events and weather context. + +### Live Timing + +Primary active-session screen. + +Shows: + +- Timing tower. +- Session clock, lap count, and track status. +- Position, gap, interval, tyre, tyre age, pit state. +- Last lap, best lap, sector state, DRS/track status where available. +- Race-control messages. +- Battles. +- Pit window predictions. +- Pinned drivers. +- Visual in-app alerts. + +### Live Track View + +Initially a mode inside Live Timing. + +Shows: + +- Circuit outline. +- Live car positions. +- Team/driver coloring. +- Selected/pinned driver focus. +- Mini timing list. +- Track/flag context where available. + +### Drivers + +Driver explorer. + +Shows: + +- Current season driver list. +- Driver profile data. +- Team, number, and headshot where available. +- Season points and trend. +- Race-by-race result table. +- Teammate comparison. +- Tyre/stint tendencies. +- Live pinned-driver mode during active sessions. + +### Standings + +Championship context screen. + +Shows: + +- Driver standings. +- Constructor standings. +- Points gaps. +- Movement since previous race. +- Race-by-race points accumulation. +- What changed after a selected Grand Prix. + +### Data Library + +Local data transparency screen. + +Shows: + +- Seasons available locally. +- Weekend and session dataset completeness. +- Missing datasets. +- Last ingested timestamps. +- Source/staleness state. +- Suggested ingestion commands. +- API lockout and stale cache explanations. + +### Settings + +Local app preferences. + +Shows: + +- Density mode. +- Theme accents. +- Preferred season. +- Pinned drivers. +- API key status. +- Data/cache path. +- Live alert preferences. + +## Navigation Model + +Primary flow: + +```text +Season -> Weekend -> Session / Race Hub +``` + +Live shortcut: + +```text +Command Center -> Live Timing -> Track / Battles / Pit Window / Race Control +``` + +Data/support flow: + +```text +Data Library -> ingestion status / missing data +``` + +Candidate routes: + +- `/` +- `/season/:year` +- `/weekend/:meetingKey` +- `/session/:sessionKey` +- `/live` +- `/drivers` +- `/drivers/:driverNumber` +- `/standings/:year` +- `/data` +- `/settings` + +## Responsive Expectations + +Phone: + +- Stacked panels. +- Sticky session/status header. +- Bottom navigation. +- Swipeable live panels. +- Compact timing rows. + +iPad: + +- Split-pane layout. +- Timing plus side panel. +- Touch-friendly controls. +- Comfortable chart inspection. + +Desktop: + +- Dense multi-column operations layout. +- Persistent side panels. +- More simultaneous context. + +Density modes should influence row height, visible columns, chart spacing, and +panel compactness. + diff --git a/documentations/refactor/05-frontend-stack.md b/documentations/refactor/05-frontend-stack.md new file mode 100644 index 0000000..c678afc --- /dev/null +++ b/documentations/refactor/05-frontend-stack.md @@ -0,0 +1,152 @@ +# Frontend Stack + +## Summary + +The next Web UI should move from embedded Alpine/static assets to a real React +application. The target is a local-first, data-heavy, live-updating race +dashboard served by the Go backend. + +## Current Web UI + +Current stack: + +- Go `net/http` server. +- Go `embed.FS` static assets. +- Plain HTML/CSS/JavaScript. +- Alpine.js from CDN. +- D3.js from CDN. +- Hash routing. +- Raw `fetch`. +- `EventSource` for live SSE. +- No frontend build system. +- No TypeScript. +- No package-managed frontend dependencies. + +This is a good prototype shape but not a good long-term foundation for the +planned Web UI. + +## Recommended Stack + +### Vite + +Purpose: + +- Frontend dev server. +- Fast TypeScript build. +- Production asset bundling. +- Clean integration with Go embedded static assets. + +### React + +Purpose: + +- Component model for complex screens. +- Good fit for live timing, charts, tables, filters, replay controls, and + persistent interaction state. + +### TypeScript + +Purpose: + +- Stronger contracts for OpenF1, local API, and live timing payloads. +- Safer refactors. +- Better developer experience across data-heavy UI. + +### TanStack Query + +Purpose: + +- Server-state fetching and caching. +- Loading/error/stale states. +- Background refresh. +- Clear handling of local DB data, API fallback, and partial data. + +### Router + +Preferred candidates: + +- TanStack Router for stronger type safety. +- React Router if simplicity and familiarity matter more. + +Routes should model product workflows rather than mimic current hash routing. + +### D3 + +Purpose: + +- Bespoke F1 visuals: + - Strategy charts. + - Track maps. + - Position evolution. + - Lap-time comparison. + - Gap history. + - Telemetry traces. + +D3 should be used where the visual is genuinely custom. Simpler chart libraries +can be considered later for generic charts. + +### Zustand + +Optional. + +Purpose: + +- Local UI preferences and cross-screen client state: + - Pinned drivers. + - Density mode. + - Selected comparison drivers. + - Visible live panels. + - Replay speed. + +Avoid adding it until React state and URL state become awkward. + +### Testing + +Vitest: + +- Formatting helpers. +- Data transforms. +- Race calculations. +- Chart input shaping. + +Playwright: + +- Page routing. +- Race Hub rendering. +- Live SSE behavior with mocked events. +- Responsive layouts. +- Data Library states. + +## Why Not Astro As The App Shell + +Astro is excellent when pages are mostly static and only specific islands need +JavaScript. `box-box` is primarily an interactive application: + +- Live timing updates. +- SSE streams. +- Dense tables. +- Replay scrubbers. +- Driver pinning. +- Interactive charts. +- Local-first data states. + +Astro could wrap React islands, but most important screens would become React +islands anyway. That adds split architecture without much benefit for this app. + +Astro may still be useful for: + +- Public docs. +- A marketing/project site. +- Static release notes. + +For the product UI, Vite + React + TypeScript is the cleaner fit. + +## Build Integration + +Target behavior: + +- During frontend development, Vite serves the React app. +- During normal `go run cmd/main.go --web`, Go serves compiled frontend assets. +- The backend remains responsible for SQLite, ingestion, OpenF1, SignalR, REST, + and SSE. + diff --git a/documentations/refactor/06-visual-design-direction.md b/documentations/refactor/06-visual-design-direction.md new file mode 100644 index 0000000..74c81ee --- /dev/null +++ b/documentations/refactor/06-visual-design-direction.md @@ -0,0 +1,123 @@ +# Visual Design Direction + +## Summary + +The visual direction should be F1-native without falling into generic dashboard +habits. The app should feel like an operations room for following a race +weekend: technical, fast, sharp, and legible. It should avoid AI-slop patterns +such as endless decorative cards, vague gradient panels, giant generic hero +sections, and meaningless visual chrome. + +## Design North Star + +Use the phrase "F1 Ops Room" as the working direction. + +Qualities: + +- Dense but controlled. +- High signal. +- Fast to scan. +- Precise typography. +- Strong hierarchy. +- Team color used as information, not decoration. +- Good on phone and iPad, not just desktop. + +## Density + +Density should be configurable: + +- Compact: timing-wall mode, maximum data per viewport. +- Comfortable: default mode for most users. +- Touch: larger hit targets and panel spacing for phone/iPad. + +Density affects: + +- Table row height. +- Visible columns. +- Panel spacing. +- Chart label detail. +- Header size. +- Control grouping. + +## Timing-Wall Ergonomics + +Live timing should prioritize scan speed: + +- Position and driver identity must be easy to locate. +- Gap/interval changes should be visually distinct. +- Pit state, retired state, and tyre state should be obvious. +- Race-control alerts should interrupt without becoming noisy. +- Pinned drivers should remain available across live views. + +## Team Color Discipline + +Team colors are useful data, but they can quickly become visual noise. + +Rules: + +- Use team color for identity markers, row accents, chart lines, and selected + driver focus. +- Avoid flooding large surfaces with saturated team color. +- Always preserve contrast and legibility. +- Avoid making the whole interface a rainbow unless the context is explicitly + comparative. + +## Layout Principles + +Prefer: + +- Full-width information bands. +- Dense tables with strong alignment. +- Split panes. +- Sticky session headers. +- Bottom navigation on phone. +- Clear panel switching on smaller screens. +- Charts that explain race state, not just decorate. + +Avoid: + +- Card sludge: every concept boxed into a decorative card. +- Floating cards inside cards. +- Generic SaaS dashboard grids. +- Purple/blue gradient panels with no product meaning. +- Decorative orbs, bokeh, or random glow effects. +- Vague hero sections. +- Overly large typography inside operational surfaces. + +## F1-Native References To Research + +Research should study: + +- Official F1 timing tower ergonomics. +- Broadcast graphics hierarchy. +- FIA timing/result sheet density. +- Race control message formatting. +- Pit wall and telemetry workstation patterns. +- Motorsport data overlays. + +The goal is not to copy official F1 branding. The goal is to understand the +information hierarchy and pacing of motorsport interfaces. + +## Mobile And iPad + +The app should work well on phone and iPad because those are likely primary +second-screen devices during race sessions. + +Phone: + +- Prioritize Live Timing, alerts, pinned drivers, and quick switching. +- Use stacked panels and sticky status. +- Keep interactions thumb-friendly. + +iPad: + +- Use two-pane and three-pane layouts. +- Keep charts inspectable. +- Make side panels easy to swap. + +Desktop: + +- Allow dense multi-panel layouts. +- Show more simultaneous context. +- Preserve keyboard and pointer efficiency. + diff --git a/documentations/refactor/07-research-agents-brief.md b/documentations/refactor/07-research-agents-brief.md new file mode 100644 index 0000000..457abbb --- /dev/null +++ b/documentations/refactor/07-research-agents-brief.md @@ -0,0 +1,206 @@ +# Research Agents Brief + +## Summary + +Before implementation tickets are written, dedicated research agents should +investigate the uncertain parts of the refactor. Their outputs should feed a +product/architecture planning pass that turns findings into phased work. + +Each research track should separate confirmed facts, assumptions, risks, and +recommendations. + +## 1. OpenF1 Contract Research + +Objective: + +- Document the exact OpenF1 endpoint contract needed by `box-box`. + +Inputs: + +- Existing `internal/api` client. +- OpenF1 docs: https://openf1.org/docs/ +- Current app screens and planned Race Hub requirements. + +Outputs: + +- Endpoint inventory. +- Field/schema notes. +- Update cadence by endpoint. +- Auth/free-tier behavior. +- Rate-limit and lockout notes. +- Essential vs optional datasets for v1. + +Key questions: + +- Which endpoints are immutable after session completion? +- Which endpoints are high-volume enough to require explicit ingestion? +- What errors are returned during live-session lockout? +- Which endpoints can be filtered to reduce ingestion cost? + +## 2. Official F1 Live Timing Research + +Objective: + +- Document the SignalR live feed contract and parser risks. + +Inputs: + +- Current `internal/ui/official_live.go`. +- SignalR endpoint: https://livetiming.formula1.com/signalr +- OpenF1.Data package notes: + https://www.nuget.org/packages/OpenF1.Data/1.0.87 + +Outputs: + +- Topic inventory. +- Payload examples where available. +- Parser fragility notes. +- Recommended domain event/state model. +- Testing strategy for non-live periods. + +Key questions: + +- Are current subscribed topics sufficient for the planned Web live mode? +- Which topics should be parsed as events vs current state? +- How should disconnections and reconnections be represented? +- Should live snapshots/events be persisted? + +## 3. Static Archive Feasibility Research + +Objective: + +- Determine whether official F1 static archived timing files should become a + supported source. + +Inputs: + +- LiveF1 data topic reference: + https://livef1.goktugocal.com/livetimingf1/data_topics.html +- Public static archive URL patterns. +- OpenF1 meeting/session metadata. + +Outputs: + +- Feasibility assessment. +- Session path mapping strategy. +- Available years/session types. +- Topic/file inventory. +- Risks and legal/operational considerations. + +Key questions: + +- Can OpenF1 sessions be mapped reliably to static archive paths? +- Are static archive files available consistently? +- Which files provide replay-quality timing? +- Is this source stable enough for v1 or later only? + +## 4. SQLite Schema And Indexing Design + +Objective: + +- Turn the domain database design into a concrete schema proposal. + +Inputs: + +- `03-database-design.md`. +- Existing `internal/models/types.go`. +- Race Hub and Live Replay query requirements. + +Outputs: + +- Table definitions. +- Primary keys and foreign keys. +- Index proposal. +- Raw payload strategy. +- Migration strategy. +- High-volume data retention recommendations. + +Design questions: + +- Which tables need composite primary keys? +- Which read paths need covering indexes? +- Should telemetry/location be optional datasets? +- Should derived read-model tables exist in v1? + +## 5. Backend API And Read-Model Design + +Objective: + +- Design the Web API shape that React will consume. + +Inputs: + +- Existing `internal/web/api.go`. +- Planned Web screens. +- Store/query requirements. + +Outputs: + +- Endpoint proposal. +- Response envelope proposal. +- Source/staleness metadata shape. +- Error/partial-data behavior. +- Migration strategy from existing endpoints. + +Design questions: + +- Should existing `/api/v1` routes be preserved and expanded? +- What metadata should every response include? +- How should partial data be represented? +- Which read models should be backend-computed vs frontend-computed? + +## 6. F1-Native Visual System Research + +Objective: + +- Produce visual principles and examples for the React UI before components are + built. + +Inputs: + +- `06-visual-design-direction.md`. +- F1 broadcast timing graphics. +- FIA timing/result sheets. +- Motorsport telemetry and timing tools. + +Outputs: + +- Moodboard or written reference guide. +- Layout principles. +- Typography and density guidance. +- Color usage rules. +- Anti-pattern list. + +Key questions: + +- How should the app look F1-native without copying official branding? +- What visual hierarchy makes live timing fastest to scan? +- How should phone/iPad layouts differ from desktop? +- How can the UI avoid generic card-heavy dashboard design? + +## 7. Testing Strategy Research + +Objective: + +- Define a test strategy for backend, ingestion, frontend, and live behavior. + +Inputs: + +- Existing tests. +- Planned store/ingestion architecture. +- Live feed limitations outside active sessions. + +Outputs: + +- Backend unit/integration test plan. +- Ingestion fixture strategy. +- Frontend Vitest and Playwright strategy. +- Mock SSE/live fixture plan. +- Manual acceptance checklist. + +Key questions: + +- How should live SignalR behavior be tested without an active session? +- What source payload fixtures are needed? +- Which scenarios require real OpenF1 integration tests? +- How should local DB migrations be tested? diff --git a/documentations/refactor/08-v1-scope-and-phasing.md b/documentations/refactor/08-v1-scope-and-phasing.md new file mode 100644 index 0000000..cbb76ed --- /dev/null +++ b/documentations/refactor/08-v1-scope-and-phasing.md @@ -0,0 +1,176 @@ +# V1 Scope And Phasing + +## Summary + +The refactor vision is intentionally broad, but the first shippable milestone +must be narrow. V1 should prove the new architecture without attempting to +finish every screen. The goal is a reliable local-first Race Hub and a cleaner +live foundation, with the existing app kept usable throughout the transition. + +## V1 Goal + +V1 is done when `box-box` can: + +- Ingest one completed race weekend into a local domain database. +- Open a Web Race Hub for that weekend without relying on fresh OpenF1 calls. +- Show honest data availability metadata. +- Continue using the existing live timing capability through an extracted live + package. +- Preserve the current TUI live mode. + +This is the first proof that the app has moved from "OpenF1 page client" to +"local-first F1 command center." + +## V1 Product Scope + +Included screens: + +- Command Center, minimal version. +- Season or Weekend entry path, minimal version. +- Race / Session Hub for completed race sessions. +- Data Library, minimal version showing local dataset status. +- Existing Web Live Timing preserved, with backend live extraction started. + +Race Hub v1 data: + +- Meeting and session metadata. +- Drivers. +- Final classification. +- Starting grid. +- Laps. +- Stints. +- Pit stops. +- Positions. +- Race control. +- Weather. +- Track outline when available. + +Race Hub v1 views: + +- Classification. +- Grid delta. +- Strategy chart. +- Position evolution. +- Lap comparison. +- Race-control timeline. +- Weather timeline. +- Dataset status. + +## V1 Non-Goals + +Not required for v1: + +- Full React replacement of every current Web screen. +- Full season backfill as a default workflow. +- Team radio audio playback. +- High-volume car telemetry ingestion by default. +- Full live-session replay from persisted SignalR data. +- Static archive ingestion. +- Browser/system notifications. +- TUI feature parity with the new Web Race Hub. + +## TUI Scope + +The TUI remains a supported live-session surface, especially because its live +mode is currently one of the strongest parts of the app. New historical, +analytics, and richer navigation work should target the Web UI first. + +TUI requirements during v1: + +- Continue compiling. +- Continue launching by default with `go run cmd/main.go`. +- Continue supporting live mode after SignalR extraction. +- Do not require Race Hub, Data Library, or React-era feature parity. + +## Backend Phase Order + +### Phase 1: Live Extraction + +- Extract SignalR connection, topic parsing, live state, and live event types + out of `internal/ui` into a reusable package such as `internal/live`. +- Keep TUI and Web mode consuming the same live package. +- Add fixture-based tests for parser behavior where possible. +- Persist live events/snapshots as a separate append-only stream only after the + extracted package has stable event/state types. +- See [09 Phase 1 Live Extraction](09-phase-1-live-extraction.md) for the + implementation brief and + [Cursor Phase 1 Prompt](cursor-phase-1-live-extraction-prompt.md) for the + fresh-agent handoff. + +### Phase 2: Store Foundation + +- Add `internal/store`. +- Add schema/migration initialization. +- Add raw payload storage. +- Add ingestion metadata tables. +- Add normalized tables required for Race Hub v1. +- Keep existing HTTP cache behavior unchanged. +- See [10 Phase 2 Store Foundation](10-phase-2-store-foundation.md) for the + implementation brief and + [Cursor Phase 2 Prompt](cursor-phase-2-store-foundation-prompt.md) for the + fresh-agent handoff. + +### Phase 3: Ingestion Foundation + +- Add `internal/ingest`. +- Support session-level and meeting-level ingestion first. +- Add dry-run output. +- Add conservative request delay, bounded retry, and 429/live-lockout handling. +- Make ingestion idempotent and resumable. +- See [11 Phase 3 Ingestion Foundation](11-phase-3-ingestion-foundation.md) for + the implementation brief and + [Cursor Phase 3 Prompt](cursor-phase-3-ingestion-foundation-prompt.md) for the + coding-agent handoff. + +### Phase 4: Local-First Web API + +- Add local-first read services for Race Hub v1. +- Introduce response metadata for source, freshness, and missing datasets. +- Migrate selected Web endpoints from direct OpenF1 calls to local-first reads. +- Allow small opportunistic fetches only for missing screen-level data. +- See [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md) for + the implementation brief and + [Cursor Phase 4 Prompt](cursor-phase-4-local-first-web-api-prompt.md) for the + coding-agent handoff. + +### Phase 5: React Race Hub Slice + +- Add Vite + React + TypeScript frontend foundation. +- Build the Race Hub v1 route and components. +- Use TanStack Query for server data. +- Use D3 for strategy, position evolution, and lap comparison visuals. +- Keep the old Web UI available until the replacement route is credible. + +## Ingestion Rate-Limit Defaults + +All bulk ingestion should be polite by default: + +- Sequential requests unless a later test proves safe concurrency. +- Configurable delay between requests. +- Bounded exponential backoff with jitter. +- Stop or pause on HTTP 429. +- Stop or pause on live-session lockout. +- Print enough progress to resume intentionally. +- Never silently launch a full-season backfill from normal Web browsing. + +## Acceptance Criteria + +V1 acceptance: + +- A completed race session can be ingested from OpenF1 into SQLite. +- Re-opening that Race Hub uses local data without fresh OpenF1 calls. +- Missing datasets are visible in the API response and UI. +- API lockout or network failure does not blank a locally ingested Race Hub. +- Existing TUI live mode still works through the extracted live package. +- The Data Library can show the ingested weekend/session and dataset state. + +## Follow-Up Phases + +After v1: + +- Expand ingestion to full seasons. +- Add static archive source if research validates it. +- Add richer live persistence and reconciliation. +- Build full Command Center, Standings, Drivers, and Settings. +- Improve mobile/iPad live layouts. +- Add broader Playwright coverage and visual regression checks. diff --git a/documentations/refactor/09-phase-1-live-extraction.md b/documentations/refactor/09-phase-1-live-extraction.md new file mode 100644 index 0000000..6c5a905 --- /dev/null +++ b/documentations/refactor/09-phase-1-live-extraction.md @@ -0,0 +1,195 @@ +# Phase 1 Live Extraction + +## Purpose + +Phase 1 creates a stable live timing foundation without changing the product +surface. The current live mode is the strongest part of `box-box`, but the core +SignalR connection and parsing code lives inside `internal/ui`. That creates a +bad dependency direction: the Web server imports TUI code only to access live +data types and `ConnectToF1LiveTiming`. + +The goal is to extract the reusable live timing core into `internal/live`, keep +the TUI and Web UI working, and add fixture-based tests around the parsing +surface. This is a foundation phase, not a frontend redesign phase. + +## Manager Decision + +I agree with Claude that Race Hub is the safest first React product slice. +However, before React work starts, the live timing backend should be separated +from the TUI. The current Web UI already depends on live data through SSE, and +future React live screens will need that source without importing terminal UI +code. + +Therefore Phase 1 is: + +- Extract the live SignalR bridge into `internal/live`. +- Update TUI live mode to consume `internal/live`. +- Update Web SSE live mode to consume `internal/live`. +- Add tests for live message parsing/state updates. +- Do not add persistence, React, or new UI behavior yet. + +## Current Coupling To Remove + +Current state: + +- `internal/ui/official_live.go` owns SignalR protocol types, live data types, + topic parsing, connection setup, and TUI rendering. +- `internal/web/live.go` imports `internal/ui` for `ui.LiveStreamData` and + `ui.ConnectToF1LiveTiming`. + +Target state: + +- `internal/live` owns reusable live data structures, SignalR protocol parsing, + connection setup, and state update logic. +- `internal/ui` owns Bubble Tea model state, keyboard behavior, and terminal + rendering. +- `internal/web` owns SSE clients, HTTP handlers, reconnect/backoff policy, and + JSON responses. + +## Proposed Package Boundary + +Add: + +```text +internal/live/ + types.go LiveStreamData, LiveDriverData, weather, race control, tyres + signalr.go negotiate/connect/subscribe to official F1 SignalR + parser.go raw message parsing and topic dispatch + state.go mutable live state accumulator and snapshot copying + parser_test.go fixture-driven tests + testdata/ small captured/synthetic SignalR messages +``` + +The exact file split can change during implementation, but the boundary should +stay clear: `internal/live` must not import `internal/ui` or Bubble Tea. + +## API Shape + +Keep a small API compatible with current callers: + +```go +package live + +type StreamData = LiveStreamData // or a normal exported type if clearer + +func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error +``` + +Optional improvements are allowed only if they stay small and do not force broad +behavior changes: + +```go +type Client struct { + // future room for custom http client, logger, topic list, clock, etc. +} + +func (c *Client) Connect(dataChan chan LiveStreamData) error +``` + +If a `Client` is introduced, preserve the top-level +`ConnectToF1LiveTiming(dataChan)` as a convenience wrapper so TUI and Web changes +remain boring. + +## What Moves From `internal/ui/official_live.go` + +Move or duplicate-then-delete these reusable concerns into `internal/live`: + +- `F1SignalRMessage` +- `F1TimingLine` +- `F1DriverListEntry` +- `LiveTyreData` +- `LiveRCMessage` +- `LiveWeatherData` +- `LiveSessionMeta` +- `LiveSectorData` +- `LiveDriverData` +- `LiveStintData` +- `LiveStreamData` +- `ConnectToF1LiveTiming` +- topic parsing and state accumulation helpers currently embedded in the + connection goroutine +- snapshot-copying logic used before sending updates + +Keep these TUI-specific concerns in `internal/ui/official_live.go`: + +- `OfficialLiveModel` +- Bubble Tea messages and commands +- viewport handling +- keybindings +- terminal render functions +- battle/pit-window display logic unless it is already pure and clearly useful + to share + +## Tests + +Live sessions are not always available, so Phase 1 tests must not depend on a +current race weekend. Add fixture-based tests in `internal/live`. + +Minimum test coverage: + +- Parse a SignalR `R` full-state message. +- Parse a SignalR `M` incremental update message. +- Handle known topics without panicking: + - `TimingData` + - `DriverList` + - `LapCount` + - `ExtrapolatedClock` + - `TrackStatus` + - `RaceControlMessages` + - `WeatherData` + - `SessionInfo` + - `CurrentTyres` + - `TimingAppData` + - `TimingStats` +- Preserve existing string/float/nested-value handling in timing fields. +- Ignore unknown topics without failing. +- Verify snapshots copy maps/slices so downstream consumers cannot mutate + internal accumulator state accidentally. + +Fixtures can be small synthetic messages shaped like the official feed. They do +not need to be full captured race payloads. + +## Acceptance Criteria + +Phase 1 is complete when: + +- `internal/web/live.go` no longer imports `internal/ui`. +- `internal/ui/official_live.go` compiles while consuming `internal/live`. +- The existing TUI live mode still uses the official F1 SignalR feed. +- The existing Web live SSE path still uses the official F1 SignalR feed. +- `go test ./...` passes. +- Parser tests run without internet access. +- No local database, React, or visual redesign work has been started as part of + this phase. + +## Non-Goals + +Do not include these in Phase 1: + +- React/Vite frontend setup. +- SQLite domain database or migrations. +- OpenF1 ingestion refactor. +- Live event persistence. +- Race Hub implementation. +- Static archive research. +- Browser notification work. +- Major rewrite of TUI live rendering. + +## Risks And Guardrails + +- The live parser currently works in practice; avoid clever rewrites that change + behavior without tests. +- Official F1 SignalR topic schemas can drift. Keep parsing tolerant of missing, + empty, string, numeric, and nested values. +- Do not make Web reconnect/backoff policy part of `internal/live` yet. The Web + server can keep owning that operational behavior. +- Do not make the TUI import Web code. Shared logic should flow through + `internal/live`. +- Preserve existing logs and user-facing behavior unless a small compile-time + adjustment requires otherwise. + +## Next Phase After This + +After Phase 1, Phase 2 should start `internal/store` and the local SQLite domain +database. Live persistence should still wait until the live data/event types have +settled and the database provenance design is ready. diff --git a/documentations/refactor/10-phase-2-store-foundation.md b/documentations/refactor/10-phase-2-store-foundation.md new file mode 100644 index 0000000..6c556c5 --- /dev/null +++ b/documentations/refactor/10-phase-2-store-foundation.md @@ -0,0 +1,136 @@ +# Phase 2 Store Foundation + +## Purpose + +Phase 2 introduces the local domain database foundation. The current SQLite +database is an HTTP response cache. That should remain intact, but it is not the +same thing as an app-owned F1 domain store. + +The goal of this phase is to add `internal/store` with schema initialization, +migrations, provenance-aware raw payload storage, and a small set of typed +domain tables needed by Race Hub v1. This phase should not build ingestion +commands or change the Web UI yet. + +## Manager Decision + +Keep this phase boring and structural. Do not try to ingest a full weekend yet. +The deliverable is a tested store package that later phases can call. + +Phase 2 should prove: + +- the app can create/open a domain SQLite database; +- migrations are repeatable and idempotent; +- raw source payloads can be stored with provenance; +- basic meeting/session/driver/session result records can be upserted and read; +- existing HTTP cache behavior is untouched. + +## Package Boundary + +Add: + +```text +internal/store/ + db.go open/close database, pragmas, transaction helper + migrations.go embedded SQL migrations and schema versioning + models.go store-layer structs for v1 domain records + raw.go raw payload/provenance writes and reads + meetings.go typed meeting/session upserts and reads + results.go typed driver/result/grid-style records as initial slice + store_test.go temp-db migration and CRUD tests +``` + +The exact file split can change, but `internal/store` should not import +`internal/ui` or `internal/web`. + +## Database Location + +Use a conservative default path separate from the existing HTTP cache: + +```text +~/.local/share/box-box/boxbox.db +``` + +Tests must use temporary databases, not the user's real home directory. + +## Initial Schema Scope + +Create tables for: + +- `schema_migrations` +- `raw_payloads` +- `ingestion_runs` +- `meetings` +- `sessions` +- `drivers` +- `session_drivers` +- `session_results` +- `starting_grid` + +It is acceptable to include additional Race Hub v1 tables if doing so is +straightforward, but do not overbuild high-volume telemetry yet. + +## Raw Payload Strategy + +`raw_payloads` should preserve source truth before normalization. + +Recommended columns: + +- source name, such as `openf1` +- endpoint or topic +- request key or URL +- meeting key when known +- session key when known +- payload JSON text/blob +- payload hash +- fetched timestamp +- provenance metadata JSON + +Raw payload storage should be idempotent by source/request/hash or another +clear uniqueness rule. + +## Domain Table Strategy + +Use stable OpenF1 identifiers where available: + +- `meeting_key` +- `session_key` +- `driver_number` + +Prefer explicit upserts over blind inserts. Completed historical data should be +safe to re-run without duplicating rows. + +## Tests + +Minimum tests: + +- opening a temp database applies migrations; +- migrations can be run twice; +- schema version is recorded; +- raw payload insert/read works and preserves provenance; +- duplicate raw payload writes do not create accidental duplicates; +- meeting/session/driver/result upserts are idempotent; +- basic Race Hub read helpers can retrieve inserted meeting/session/result data. + +## Non-Goals + +Do not include these in Phase 2: + +- OpenF1 backfill orchestration. +- CLI ingestion commands. +- Web UI changes. +- React setup. +- Replacing existing `internal/api/cache.go`. +- High-volume telemetry tables for car data/location. +- Live SignalR persistence. + +## Acceptance Criteria + +Phase 2 is complete when: + +- `internal/store` exists with tested migration and CRUD behavior. +- The package can create a fresh SQLite domain database. +- Running migrations repeatedly is safe. +- Store tests pass without internet access. +- `go test ./internal/store/...` passes. +- `go test ./...` either passes or only fails because existing OpenF1 + integration tests cannot reach the network/API. diff --git a/documentations/refactor/11-phase-3-ingestion-foundation.md b/documentations/refactor/11-phase-3-ingestion-foundation.md new file mode 100644 index 0000000..95dc6bc --- /dev/null +++ b/documentations/refactor/11-phase-3-ingestion-foundation.md @@ -0,0 +1,163 @@ +# Phase 3 Ingestion Foundation + +## Purpose + +Phase 3 connects OpenF1 REST data to the local domain store introduced in Phase +2. The goal is to ingest a meeting or session intentionally, record provenance, +write raw payloads, normalize the initial Race Hub datasets, and make the work +idempotent and resumable. + +This phase should still avoid Web UI replacement work. It creates the backend +path that later Race Hub APIs and React screens can trust. + +## Manager Decision + +Build ingestion as an explicit backend workflow first, not as an automatic Web +side effect. Normal browsing must not accidentally trigger a full weekend +backfill or burn through API quota. + +Phase 3 should add: + +- `internal/ingest` orchestration. +- OpenF1 source-to-store mapping for the Phase 2 tables. +- A small CLI command path for manual ingestion. +- Dry-run and progress output. +- conservative retry/rate-limit behavior. + +## Package Boundary + +Add: + +```text +internal/ingest/ + ingest.go orchestrator, options, result summary + openf1.go OpenF1 source adapter and model mapping + progress.go progress event/output helpers if useful + ingest_test.go fake-source/fake-store or temp-db tests +``` + +The package should depend on: + +- `internal/api` for OpenF1 reads; +- `internal/store` for writes; +- `internal/models` for current OpenF1 response structs. + +It should not depend on: + +- `internal/ui`; +- `internal/web`; +- React/frontend code. + +## Initial Ingestion Scope + +Support these commands/workflows first: + +- ingest meetings for a year; +- ingest sessions for a meeting; +- ingest a single session's Race Hub v1 datasets. + +For a race session, ingest: + +- meeting metadata when available; +- session metadata; +- drivers; +- session result; +- starting grid; +- raw payload records for each fetched endpoint. + +If Cursor chooses to include laps, stints, pits, race control, or weather, the +store schema must support them first. Otherwise leave those datasets for Phase +4 or a Phase 3 follow-up. Do not jam JSON blobs into unrelated tables just to +claim coverage. + +## CLI Shape + +Extend `cmd/main.go` conservatively. Keep the default TUI and `--web` behavior +unchanged. + +Recommended flags: + +```bash +go run cmd/main.go --ingest-year 2025 +go run cmd/main.go --ingest-meeting 1229 +go run cmd/main.go --ingest-session 9472 +go run cmd/main.go --ingest-session 9472 --dry-run +go run cmd/main.go --ingest-session 9472 --db /path/to/boxbox.db +``` + +This is acceptable as a first CLI slice. A richer subcommand framework can wait. + +## Ingestion Behavior + +Defaults: + +- sequential requests; +- small delay between endpoint calls; +- bounded retry for transient failures; +- stop cleanly on OpenF1 live-session lockout; +- no silent full-season backfills; +- print progress and final summary; +- write raw payload provenance for each endpoint; +- upsert normalized records so reruns are safe. + +## Raw Payload Provenance + +Each fetched endpoint should record: + +- source: `openf1`; +- endpoint name; +- request key; +- meeting key when known; +- session key when known; +- fetched timestamp; +- raw JSON payload; +- HTTP/API provenance when available; +- whether data came from stale cache if that signal is available. + +If the current API client does not expose raw JSON easily, prefer a small source +adapter enhancement over duplicating HTTP logic wildly. Keep existing cache +behavior intact. + +## Tests + +Tests should avoid real network calls. + +Minimum tests: + +- ingesting a fake session writes drivers, results, grid rows, and raw payloads; +- rerunning the same ingestion does not duplicate normalized rows; +- dry-run does not write domain rows; +- source errors stop the run and record/report failure; +- live-session lockout is surfaced as a controlled failure; +- CLI flag parsing does not break default TUI/Web behavior if covered cheaply. + +## Non-Goals + +Do not include these in Phase 3: + +- React/Vite frontend implementation. +- Web Race Hub API replacement. +- automatic Web-triggered backfill. +- live SignalR persistence. +- full-season default backfill. +- static archive ingestion. +- high-volume car telemetry ingestion. + +## Acceptance Criteria + +Phase 3 is complete when: + +- `internal/ingest` exists and is covered by offline tests. +- A user can manually ingest a year, meeting, or session from the CLI. +- Rerunning ingestion is idempotent. +- Raw payloads and normalized records are both written. +- `go test ./internal/ingest/... ./internal/store/...` passes. +- `go build -o /tmp/box-box ./cmd/main.go` passes. +- `go test ./...` either passes or only fails because existing OpenF1 + integration tests cannot reach the network/API. + +## Next Phase After This + +Phase 4 should add local-first backend read models and Web API endpoints for +Race Hub v1. It should make the Web API prefer local SQLite data and report +missing datasets honestly. diff --git a/documentations/refactor/12-phase-4-local-first-web-api.md b/documentations/refactor/12-phase-4-local-first-web-api.md new file mode 100644 index 0000000..04ff465 --- /dev/null +++ b/documentations/refactor/12-phase-4-local-first-web-api.md @@ -0,0 +1,139 @@ +# Phase 4 Local-First Web API + +## Purpose + +Phase 4 makes the Web API start behaving like a local-first product. Phases 2 +and 3 created the domain store and explicit ingestion path; this phase adds +read models that prefer local SQLite data and report data availability honestly. + +This is still a backend phase. Do not start React yet. + +## Manager Decision + +Build one credible local-first Race Hub API slice before replacing the frontend. +The current Web UI can keep working from the existing endpoints, but the backend +should expose store-backed responses that a future React Race Hub can trust. + +Phase 4 should add: + +- store-backed read models for ingested meetings, sessions, drivers, results, + and grid; +- dataset/status metadata so the UI knows what is local, missing, or stale; +- optional small API fallbacks only when explicitly requested; +- tests for local-first behavior without network. + +## Package Boundary + +Prefer adding a backend read-model layer instead of embedding SQL inside HTTP +handlers. + +Recommended shape: + +```text +internal/query/ + racehub.go Race Hub read model assembly + metadata.go dataset availability/source metadata + query_test.go temp-db tests +``` + +Then wire `internal/web` to use that layer. + +If the implementation keeps the read layer inside `internal/web` temporarily, +it must still avoid duplicating store SQL across handlers. + +## Initial API Scope + +Add a new Race Hub endpoint: + +```text +GET /api/v1/race-hub?session_key=9472 +``` + +Response should include: + +- meeting; +- session; +- drivers; +- session results enriched with driver/team fields; +- starting grid enriched with driver/team fields; +- dataset availability metadata. + +Recommended metadata shape: + +```json +{ + "source": "local", + "session_key": 9472, + "datasets": { + "meeting": {"status": "available", "source": "local"}, + "session": {"status": "available", "source": "local"}, + "drivers": {"status": "available", "source": "local", "count": 20}, + "results": {"status": "missing", "source": "none", "count": 0}, + "starting_grid": {"status": "available", "source": "local", "count": 20} + } +} +``` + +Exact field names can vary, but the response must make missing datasets visible +instead of silently returning empty app states. + +## Existing Endpoint Policy + +Do not rewrite every existing endpoint yet. It is enough to: + +- add the new local-first Race Hub endpoint; +- optionally make `/api/v1/meetings`, `/api/v1/sessions`, `/api/v1/drivers`, + `/api/v1/results`, and `/api/v1/grid` read from local data when present; +- preserve old OpenF1 behavior when local data is absent unless the request asks + for local-only behavior. + +Recommended query controls: + +```text +?source=local local only; no OpenF1 fallback +?source=auto local first, existing OpenF1 fallback when missing +``` + +Default should be conservative for existing endpoints. The new Race Hub endpoint +can default to local-first with honest missing metadata. + +## Server Wiring + +`web.Server` currently only receives `*api.OpenF1Client`. Add an optional +`*store.Store` or query service so Web mode can read the domain DB. + +CLI/server behavior should remain simple: + +```bash +go run cmd/main.go --web +go run cmd/main.go --web --db /path/to/boxbox.db +``` + +If the DB does not exist or has no ingested data, Web mode should still start. + +## Non-Goals + +Do not include these in Phase 4: + +- React/Vite frontend setup. +- replacing the current static Web UI; +- automatic ingestion from Web browsing; +- live SignalR persistence; +- laps/stints/pits/weather/race-control read models unless the store schema is + expanded and tested first. + +## Acceptance Criteria + +Phase 4 is complete when: + +- a local-first Race Hub endpoint exists; +- it can return ingested session data without OpenF1 calls; +- it reports missing datasets explicitly; +- Web mode can be pointed at a domain DB with `--db`; +- offline tests cover the read model and HTTP handler behavior; +- focused tests and build pass. + +## Next Phase After This + +Phase 5 is the first frontend implementation phase. That is the point to switch +from Cursor to Claude for React/UI work. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md new file mode 100644 index 0000000..6774b47 --- /dev/null +++ b/documentations/refactor/README.md @@ -0,0 +1,86 @@ +# box-box Refactor Brief + +## Purpose + +This directory captures the planning baseline for the next major evolution of +`box-box`. The current project has a strong live timing core, especially through +the official F1 live feed, but the rest of the app still behaves like an +on-demand OpenF1 client. That makes historical and session data unreliable, +especially during live-session API lockouts. + +The refactor direction is to make the Web UI the primary product surface, make +historical data local-first, and preserve the TUI live mode that already works +well. These documents are intentionally strategic and research-ready. They are +not implementation tickets yet. + +## Strategic Defaults + +- Frontend: React + TypeScript, built with Vite. +- Backend: Go remains the application and API server. +- Storage: SQLite becomes a real local domain database, not only an HTTP cache. +- Historical ingestion: OpenF1 REST is the first ingestion/backfill source. +- Live timing: official F1 SignalR remains the live source. +- Ingestion model: explicit CLI backfill plus opportunistic small web fetches. +- TUI: preserve the current live mode; new historical/analytics work focuses on + the Web UI first. +- Product stance: race-weekend first, local-first, no rushed implementation. +- Live persistence: persist live SignalR events/snapshots as a separate + append-only stream once the live bridge is extracted; do not merge them into + post-session OpenF1 records without a reconciliation design. +- Migration: keep the current raw HTTP cache behavior intact while introducing + the new domain database incrementally. + +## Documents + +- [01 Data Sources](01-data-sources.md): current and candidate data sources, + source authority, limitations, and open questions. +- [02 Backend Architecture](02-backend-architecture.md): proposed backend + packages, local-first reads, ingestion policy, and live bridge boundaries. +- [03 Database Design](03-database-design.md): target SQLite strategy, raw + payload storage, normalized tables, provenance, and research questions. +- [04 Web UI Product](04-web-ui-product.md): screen architecture, navigation, + responsive behavior, and product priorities. +- [05 Frontend Stack](05-frontend-stack.md): React stack choice and supporting + libraries. +- [06 Visual Design Direction](06-visual-design-direction.md): F1-native visual + principles and anti-patterns to avoid. +- [07 Research Agents Brief](07-research-agents-brief.md): research tracks for + dedicated agents before ticket planning. +- [08 V1 Scope and Phasing](08-v1-scope-and-phasing.md): first shippable + milestone, non-goals, phase order, and early implementation sequence. +- [09 Phase 1 Live Extraction](09-phase-1-live-extraction.md): first coding + slice, package boundaries, tests, acceptance criteria, and non-goals. +- [10 Phase 2 Store Foundation](10-phase-2-store-foundation.md): second coding + slice for introducing the local SQLite domain store without changing product + behavior. +- [11 Phase 3 Ingestion Foundation](11-phase-3-ingestion-foundation.md): third + coding slice for OpenF1-to-store ingestion orchestration. +- [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md): fourth + coding slice for store-backed Race Hub read models and Web API metadata. +- [Cursor Phase 4 Prompt](cursor-phase-4-local-first-web-api-prompt.md): + current handoff prompt for the next Cursor backend phase. + +## External References + +- OpenF1 documentation: https://openf1.org/docs/ +- Official F1 SignalR endpoint: https://livetiming.formula1.com/signalr +- LiveF1 timing topic reference: + https://livef1.goktugocal.com/livetimingf1/data_topics.html +- OpenF1.Data package notes on F1 SignalR: + https://www.nuget.org/packages/OpenF1.Data/1.0.87 + +## Current Repo Context + +The existing application already has: + +- A Go OpenF1 client in `internal/api`. +- A SQLite-backed raw HTTP cache and track outline persistence. +- A Bubble Tea TUI in `internal/ui`. +- A Go-served Web UI in `internal/web`. +- A live SignalR bridge extracted into `internal/live` and reused by both TUI + and Web mode through server-sent events. + +The refactor should build on that progress instead of replacing it blindly. +The goal is to separate source fetching, domain persistence, query/read models, +and frontend experience so each layer can be improved without destabilizing the +others. diff --git a/documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md b/documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md new file mode 100644 index 0000000..da8d068 --- /dev/null +++ b/documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md @@ -0,0 +1,110 @@ +# Cursor Prompt: Phase 4 Local-First Web API + +You are working in the `box-box` repository. + +Phases 1-3 are complete: + +- `internal/live` owns shared live timing. +- `internal/store` owns the SQLite domain DB. +- `internal/ingest` can ingest initial OpenF1 data into the store. + +Your task is Phase 4: add local-first backend read models and Web API support. +This is still a backend phase. Do not start the React/frontend implementation. + +## Read First + +Read these files before editing: + +- `CLAUDE.md` +- `documentations/refactor/08-v1-scope-and-phasing.md` +- `documentations/refactor/12-phase-4-local-first-web-api.md` +- `internal/store/*` +- `internal/ingest/*` +- `internal/web/server.go` +- `internal/web/api.go` +- `cmd/main.go` + +## Goal + +Expose a store-backed Race Hub API that can return ingested data without making +fresh OpenF1 calls. Missing datasets must be explicit in response metadata. + +## Required Work + +1. Add a read-model layer, preferably `internal/query`. +2. Implement a Race Hub read model for a single `session_key`. +3. Include: + - meeting; + - session; + - drivers; + - session results enriched with driver/team fields; + - starting grid enriched with driver/team fields; + - dataset availability metadata. +4. Add a Web endpoint: + + ```text + GET /api/v1/race-hub?session_key=9472 + ``` + +5. Wire Web mode to optionally open the domain DB: + + ```bash + go run cmd/main.go --web --db /path/to/boxbox.db + ``` + +6. Web mode must still start when the DB is absent or empty. +7. Add offline tests using temp SQLite stores. +8. Preserve existing TUI and live behavior. + +## Optional Work + +If straightforward, make these existing endpoints support local-first reads: + +- `/api/v1/meetings` +- `/api/v1/sessions` +- `/api/v1/drivers` +- `/api/v1/results` +- `/api/v1/grid` + +Use query controls such as: + +```text +?source=local +?source=auto +``` + +Do not break the current OpenF1-backed behavior of existing endpoints. + +## Guardrails + +- Do not add React, Vite, TanStack, or frontend app code. +- Do not trigger ingestion from normal Web browsing. +- Do not persist SignalR live data. +- Do not rewrite every API endpoint. +- Do not add laps/stints/pits/weather/race-control read models unless you also + add tested store tables for them. +- Keep tests offline. + +## Testing + +Run: + +```bash +go test ./internal/query/... ./internal/web/... ./internal/store/... +go build -o /tmp/box-box ./cmd/main.go +go test ./... +``` + +If `go test ./...` fails only because existing `internal/api` integration tests +cannot reach OpenF1, report that separately as unrelated. + +## Final Response + +Report: + +- packages/files changed; +- endpoint(s) added; +- response metadata shape; +- tests run and results; +- any known limitations; +- whether Phase 5 can begin frontend work. diff --git a/documentations/refactor/screens/command-center.html b/documentations/refactor/screens/command-center.html new file mode 100644 index 0000000..d8966ea --- /dev/null +++ b/documentations/refactor/screens/command-center.html @@ -0,0 +1,683 @@ + + + + + + box-box — Command Center + + + + + + +
+ + + + + +
+ + +
+
+ Round 8 + + + Race In Progress + + Open Live → +
+ +
+ 🇲🇨 +
+
Monaco Grand Prix
+
Circuit de Monaco · Monte Carlo · 22–25 May 2025
+
+
+
+ + + + + +
+
+ Race — In Progress + Open Live Timing → +
+
+
+
Lap
+
45/78
+
+
+
Leader
+
LEC
+
+
+
Gap P1–P2
+
+3.4s
+
+
+
Track
+
● GREEN
+
+
+
+ + +
+
+ Weekend Schedule + Circuit de Monaco · 3.337 km · 78 laps +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SessionDateLocal TimeUTCStatusData
FP1Thu 22 May11:3009:30DoneFull
FP2Thu 22 May15:0013:00DoneFull
FP3Sat 24 May11:3009:30DoneFull
QualifyingSat 24 May15:0013:00DoneFull
RaceSun 25 May14:0012:00LiveStreaming
+
+ + +
+
+ Weather + Race day · updated 2 min ago +
+
+
+ Air + 24°C +
+
+ Track + 36°C +
+
+ Humidity + 62% +
+
+ Wind + 7 km/h +
+
+ Direction + NW +
+
+ Rain Risk + 2% +
+
+ Pressure + 1014 hPa +
+
+
+ +
+ + + +
+ + + + diff --git a/documentations/refactor/screens/component-notes.md b/documentations/refactor/screens/component-notes.md new file mode 100644 index 0000000..0485566 --- /dev/null +++ b/documentations/refactor/screens/component-notes.md @@ -0,0 +1,375 @@ +# Component Notes + +Implementation reference for translating the static mockups into React components. +Each entry covers purpose, screens, data shape, and responsive behaviour. + +--- + +## App Navigation — `` + +**Purpose**: Persistent sticky top bar across all screens. Shows logo, route links, live status badge, and density toggle. + +**Appears in**: All screens (always mounted). + +**Props / data needed**: +```ts +interface AppNavProps { + activePage: 'home' | 'live' | 'race-hub' | 'data-library' | 'standings' | 'drivers' + isLive: boolean // shows animated red dot + RACE/QUALI badge + sessionLabel?: string // e.g. "Monaco GP — Race" + densityMode: 'default' | 'compact' + onDensityChange: (mode) => void +} +``` + +**Responsive**: On phone (<768px), collapse nav links to icon-only or a hamburger. Live badge and density toggle remain visible. Session label moves to the session banner. + +--- + +## Density Toggle — `` + +**Purpose**: Switches between default and compact row heights. Effect is applied as a CSS class on ``, not via React state cascade. + +**Appears in**: ``. + +**Implementation note**: Call `document.documentElement.classList.toggle('compact', ...)` directly. Store preference in `localStorage`. React state only tracks the value for rendering the active button — does not gate CSS. + +**Responsive**: Always visible. Two-button [D][C] works at any width. + +--- + +## Session Status Banner — `` + +**Purpose**: Real-time session state strip: session name, current lap, session clock, track status, DRS state, fastest lap. Fed by SSE. + +**Appears in**: `/live` (always visible at top, sticky). On phone, replaces AppNav as the primary context header. + +**Props / data needed**: +```ts +interface SessionBannerProps { + sessionName: string // "Monaco GP — Race" + lapCurrent: number + lapTotal: number + sessionClock: string // "1:02:34" + trackStatus: 'green' | 'yellow' | 'red' | 'sc' | 'vsc' | 'unknown' + drsEnabled: boolean + fastestLap?: { driverCode: string; lapTime: number; lap: number } + airTemp: number + trackTemp: number + isConnected: boolean // SSE connection state +} +``` + +**Critical state**: `isConnected: false` must show a visible DISCONNECTED indicator. Do not silently go stale. + +**Responsive**: Horizontal scroll on narrow viewports. On phone, show session + lap + track status as minimum; other fields scroll off-screen. + +--- + +## Timing Tower — `` + +**Purpose**: Dense real-time table. One row per driver. Driven by SSE. This is the primary surface of the live screen. + +**Appears in**: `/live`. + +**Props / data needed**: +```ts +interface TimingEntry { + position: number + driverCode: string + driverNumber: number + teamColor: string + gap: string // "LEADER" | "+3.456" | "+1 LAP" + interval: string // "+3.456" | "—" + tyre: 'S' | 'M' | 'H' | 'I' | 'W' + tyreAge: number // laps on current tyre + lastLap: number // seconds + bestLap: number // seconds + s1State: 'pb' | 'ob' | 'slow' | 'none' + s2State: 'pb' | 'ob' | 'slow' | 'none' + s3State: 'pb' | 'ob' | 'slow' | 'none' + isPitIn: boolean + isPitOut: boolean + hasFastestLap: boolean + isDNF: boolean +} + +interface TimingTowerProps { + entries: TimingEntry[] + pinnedDrivers: string[] // driver codes + fastestLapDriver: string +} +``` + +**Performance critical**: SSE ticks every 1–2 seconds. Memoize `` by driver number. Diff at the entry level, not the full array. Use `React.memo` + stable references. Avoid full re-renders. + +**Columns on phone**: P · Driver · Gap · Tyre · Last (hide Int, Age, Best, Sectors). +**Columns on iPad**: P · Driver · Gap · Int · Tyre · Age · Last (hide Best, Sectors). +**Columns on desktop**: All columns. + +--- + +## Race Control Feed — `` + +**Purpose**: Scrolling list of race control messages. Auto-scrolls to newest. Color-coded by message type (SC, VSC, DRS, penalty, fastest lap, flag). + +**Appears in**: `/live` sidebar, `/race-hub` Race Control tab (static version). + +**Props / data needed**: +```ts +interface RCMessage { + id: string + lap: number + type: 'sc' | 'vsc' | 'drs' | 'penalty' | 'fl' | 'flag' | 'info' + text: string + timestamp: string +} + +interface RaceControlFeedProps { + messages: RCMessage[] + autoScroll: boolean +} +``` + +**Responsive**: In `/live` sidebar on desktop. On phone, a full-screen panel accessed via bottom tab. In race-hub, inline full-width list. + +--- + +## Battle Row / Battles Panel — `` + +**Purpose**: Shows detected on-track pairs with gap value and closing/stable/opening trend. + +**Appears in**: `/live` sidebar. + +**Props / data needed**: +```ts +interface Battle { + aheadCode: string + aheadTeamColor: string + behindCode: string + behindTeamColor: string + gapSeconds: number + trend: 'closing' | 'stable' | 'opening' +} + +interface BattlesListProps { + battles: Battle[] +} +``` + +**Responsive**: On phone, shown in the Battles bottom-tab panel alongside Pit Window. + +--- + +## Pinned Driver Strip — `` + +**Purpose**: Compact horizontal cards for drivers the user has pinned. Shows position, gap, last lap, tyre state at a glance without scrolling the timing tower. + +**Appears in**: `/live` (below session banner, above timing tower). Hidden on phone to preserve space. + +**Props / data needed**: +```ts +interface PinnedCardData { + driverCode: string + teamColor: string + position: number + gap: string + lastLap: string + tyre: string + specialState?: 'pit-out' | 'fastest-lap' | 'dnf' +} + +interface PinnedStripProps { + cards: PinnedCardData[] + onUnpin: (driverCode: string) => void +} +``` + +**Responsive**: Desktop + iPad only. Hide on phone (<768px) — tower already shows all data. + +--- + +## Pit Window Panel — `` + +**Purpose**: For each driver with a notable stint age, shows current tyre, age, and pit window status (OPEN / SOON / OVERDUE / DONE). Computed from stint age and expected compound life. + +**Appears in**: `/live` sidebar (third section after RC and Battles). + +**Props / data needed**: +```ts +interface PitWindowEntry { + driverCode: string + teamColor: string + tyre: 'S' | 'M' | 'H' | 'I' | 'W' + tyreAge: number + windowStatus: 'open' | 'soon' | 'overdue' | 'done' +} +``` + +**Note**: Window status logic lives in the backend or a pure TS utility — not component logic. + +**Responsive**: Desktop sidebar. On phone, shown in Battles tab panel below battle list. + +--- + +## Strategy Chart — `` + +**Purpose**: D3-owned horizontal stint chart. One row per driver (top 10), colored blocks = tyre compound, width = laps. SC/VSC period shading. Lap counter axis. + +**Appears in**: `/race-hub` Strategy tab. + +**Props / data needed**: +```ts +interface Stint { + compound: 'S' | 'M' | 'H' | 'I' | 'W' + startLap: number + endLap: number +} + +interface StrategyDriver { + code: string + teamColor: string + stints: Stint[] +} + +interface StrategyChartProps { + drivers: StrategyDriver[] + totalLaps: number + scPeriods: Array<{ start: number; end: number }> + vscPeriods: Array<{ start: number; end: number }> +} +``` + +**D3 contract**: Component owns its SVG DOM node. Mount/update via `useEffect` with D3. Resize via `ResizeObserver`. No React inside the SVG. + +**Responsive**: Full-width SVG with `viewBox`, scales with container. Label column width fixed. On narrow screens (< 480px), driver labels may need abbreviating. + +--- + +## Position Evolution Chart — `` + +**Purpose**: D3-owned line chart. X = lap, Y = position (1 at top). One line per driver (top 6). SC/VSC period bands. End labels per driver. + +**Appears in**: `/race-hub` Positions tab. + +**Props / data needed**: +```ts +interface PositionPoint { lap: number; position: number } + +interface PositionDriver { + code: string + color: string + dashed: boolean // true for teammate (same team color) + points: PositionPoint[] +} + +interface PositionEvolutionProps { + drivers: PositionDriver[] + totalLaps: number + scPeriods: Array<{ start: number; end: number }> + vscPeriods: Array<{ start: number; end: number }> +} +``` + +**D3 contract**: Same as StrategyChart — D3 owns the SVG, React manages data and container sizing. + +**Responsive**: Full-width `viewBox` SVG. Right-side labels need right-padding. + +--- + +## Dataset Status Indicator — `` + +**Purpose**: Shows completeness of local data for a session. Used in two modes: full grid (Race Hub Dataset tab, Data Library detail) and mini (Race Hub aside, API response metadata strip). + +**Appears in**: `/race-hub` Dataset tab, `/race-hub` aside, `/data-library` detail panel. API response metadata. + +**Props / data needed**: +```ts +type DatasetState = 'local' | 'partial' | 'missing' | 'stale' | 'live' + +interface DatasetEntry { + name: string // e.g. "laps", "car_data_samples" + state: DatasetState + lastIngestedAt?: string + error?: string +} + +interface DatasetStatusProps { + datasets: DatasetEntry[] + variant: 'grid' | 'mini' | 'strip' +} +``` + +**Responsive**: Grid variant reflows to 1-column on phone. Mini variant stays compact at all widths. Strip variant is a horizontal overflow row (source strip in the sub-header). + +--- + +## Ingest Command Block — `` + +**Purpose**: Displays one or more CLI ingest commands with syntax highlighting (comment lines, command lines). Not interactive — display only. + +**Appears in**: `/race-hub` Dataset tab, `/data-library` detail panel. + +**Props / data needed**: +```ts +interface IngestLine { + type: 'comment' | 'command' + text: string +} + +interface IngestCommandBlockProps { + lines: IngestLine[] +} +``` + +**Responsive**: Horizontal scroll on overflow. Monospace font required. + +--- + +## Responsive Panel Shell — `` + +**Purpose**: Layout wrapper that switches between sidebar-on-right (desktop), stacked (tablet), and tab-driven (phone) based on viewport. Used in Live Timing and Race Hub. + +**Appears in**: Used internally by `/live` and `/race-hub`. + +**Props / data needed**: +```ts +interface PanelShellProps { + main: React.ReactNode // always visible + aside: React.ReactNode // sidebar on desktop, bottom on tablet + phoneTabs?: Array<{ // only on phone — replaces aside with tabs + id: string + label: string + icon: string + content: React.ReactNode + }> + asideWidth?: number // default 300 +} +``` + +**Responsive**: +- Desktop (>1024px): `main | aside` grid +- iPad (769–1024px): `main | aside` with narrower aside +- Phone (<768px): `main` full-width + bottom tab nav switching aside panels + +**Implementation note**: Density mode class on `` flows through without prop drilling. `PanelShell` does not need to know about density — CSS handles it. + +--- + +## Source/Freshness Strip — `` + +**Purpose**: Inline metadata bar attached to API responses. Shows data source, last ingest time, staleness state, and list of missing datasets. + +**Appears in**: Sub-headers of `/race-hub`, `/command-center` (data status for weekend), any screen that reads from the local DB. + +**Props / data needed**: +```ts +interface SourceStripProps { + source: 'local' | 'api' | 'cache' | 'live' | 'missing' + lastIngestedAt?: string + isStale?: boolean + missingDatasets?: string[] +} +``` + +**Responsive**: Wraps on narrow viewports. Badges remain readable at any width. diff --git a/documentations/refactor/screens/data-library.html b/documentations/refactor/screens/data-library.html new file mode 100644 index 0000000..cd9b955 --- /dev/null +++ b/documentations/refactor/screens/data-library.html @@ -0,0 +1,651 @@ + + + + + + box-box — Data Library + + + + + + +
+ + + + + +
+ + +
+ 2025 Season + 24 rounds · 7 complete · 2 partial · 1 live + +
+ +
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RndWeekendDateStatusSessionsLast Sync
1🇧🇭 Bahrain GP2 MarFull +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
2d ago
2🇸🇦 Saudi Arabia GP16 MarFull +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
5d ago
3🇦🇺 Australian GP30 MarFull +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
8d ago
4🇯🇵 Japanese GP13 AprPartial +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
21d ago
5🇨🇳 Chinese GP20 AprPartial +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
28d ago
6🇺🇸 Miami GP4 MayFull +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
14d ago
7🇮🇹 Emilia Romagna GP18 MayFull +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
3d ago
8🇲🇨 Monaco GP25 MayLive +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
Now
9🇨🇦 Canadian GP13 JunMissing +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
10🇦🇹 Austrian GP27 JunMissing +
+
F1
+
F2
+
F3
+
Q
+
R
+
+
+ + 14 upcoming rounds not yet available +
+
+ + +
+ +
+
+ 🇧🇭 Bahrain Grand Prix + Full +
+
Round 1 · 2 March 2025 · meeting_key 1230
+
Ingested: 2 Mar 2025 18:42 · 142 MB across 5 sessions
+
+ + +
+
+ FP1 + session_key 9120 +
+
+
laps2d ago
+
stints2d ago
+
weather2d ago
+
car_data_samplesnot ingested
+
+
+ + +
+
+ Race + session_key 9125 + +
+
+
session_results2d ago
+
starting_grid2d ago
+
laps2d ago
+
stints2d ago
+
pit_stops2d ago
+
positions2d ago
+
race_control2d ago
+
weather2d ago
+
car_data_samplesnot ingested
+
location_samplesnot ingested
+
team_radionot ingested
+
+
+ + +
+
+ Ingest Commands +
+
+
# Full weekend ingest (all sessions, default datasets)
+
box-box --ingest-meeting 1230
+
+
# Race session only
+
box-box --ingest-session 9125
+
+
# High-volume telemetry (explicit, large download)
+
box-box --ingest-session 9125 --datasets car_data,location
+
+
# Preview without downloading
+
box-box --ingest-meeting 1230 --dry-run
+
+
+ +
+ +
+ +
+ +
+ + + + diff --git a/documentations/refactor/screens/index.html b/documentations/refactor/screens/index.html new file mode 100644 index 0000000..3c9178e --- /dev/null +++ b/documentations/refactor/screens/index.html @@ -0,0 +1,180 @@ + + + + + + box-box — Screen Index + + + + + + + + + + + diff --git a/documentations/refactor/screens/live-timing.html b/documentations/refactor/screens/live-timing.html new file mode 100644 index 0000000..c775b47 --- /dev/null +++ b/documentations/refactor/screens/live-timing.html @@ -0,0 +1,774 @@ + + + + + + box-box — Live Timing · Monaco GP Race + + + + + + +
+ + +
+
+ Session + Monaco GP — Race +
+
+ Lap + 45 / 78 +
+
+ Clock + 1:02:34 +
+
+ Track + ● GREEN +
+
+ DRS + ENABLED +
+
+ Air / Track + 25°C / 38°C +
+
+ Fastest Lap + NOR 1:14.756 +
+
+ + +
+ Pinned +
+ P1 +
+
+ LEC +
+ LEADER + 1:15.234 +
+
+ P3 +
+
+ NOR +
+ +7.2 + FL 1:14.756 +
+
+ P8 +
+
+ SAI +
+ PIT OUT + 1:41.678 +
+ Pin drivers with P in TUI or browser shortcut +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PDriverGapIntTyreAgeLastBestS1 S2 S3
1
LEC16
LEADERM121:15.2341:14.892
2
VER1
+3.456+3.456H81:15.6231:15.023
3
NOR4
+7.234+3.778H281:15.0121:14.756
4
PIA81
+12.567+5.333H221:15.8901:15.234
5
RUS63
+18.234+5.667H141:15.4561:15.100
6
HAM44
+24.567+6.333M81:16.0121:15.567
7
ALO14
+31.234+6.667H431:16.2341:15.890
8 +
+
+ SAI + 55 + PIT OUT +
+
+38.901+7.667S21:41.6781:15.456
9
GAS10
+45.234+6.333H371:16.8901:16.234
10
OCO31
+52.567+7.333H231:17.0121:16.567
11
TSU22
+1 LAPH451:17.4561:17.012
+
+
+ + +
+ + +
+
+ Race Control + +
+
+
+ L27 + DRS ENABLED — Lap 27 +
+
+ L26 + SAFETY CAR IN THIS LAP +
+
+ L23 + SC DEPLOYED — ALB retirement T10 +
+
+ L35 + 5s PENALTY — RUS · Unsafe release +
+
+ L38 + FASTEST LAP — NOR 1:14.756 +
+
+ L3 + DRS ENABLED — Lap 3 +
+
+ L1 + RACE START — Track Clear +
+
+
+ + +
+
+ Battles +
+
+
+
+ NOR + vs + PIA +
+ +5.3s + +
+
+
+ HAM + vs + ALO +
+ +6.3s + +
+
+
+ SAI + vs + GAS +
+ +6.4s + +
+
+
+ + +
+
+ Pit Window + L45 +
+
+
+
+
+ LEC +
+ M + L12 + SOON +
+
+
+
+ VER +
+ H + L8 + OPEN +
+
+
+
+ NOR +
+ H + L28 + OVERDUE +
+
+
+
+ RUS +
+ H + L14 + OPEN +
+
+
+
+ SAI +
+ S + L2 + DONE +
+
+
+ +
+
+ + + + + + + + + + + + + + +
+ + + + diff --git a/documentations/refactor/screens/mobile-live.html b/documentations/refactor/screens/mobile-live.html new file mode 100644 index 0000000..428036f --- /dev/null +++ b/documentations/refactor/screens/mobile-live.html @@ -0,0 +1,939 @@ + + + + + +box-box · Live Timing — Phone Layout + + + + +
+ Mobile Live Layout Demo · 390px phone viewport · + ← full responsive version · + index +
+ + +
+ + +
+
+
Monaco GP — Race
+
Lap 47/78
+
SC
+
+ + +
+ + + + + SSE disconnected — data may be stale + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PDriverGapTyreLast
1 +
+
+ LEC +
+
LEADERM1:14.812
2 +
+
+ VER +
+
+3.456M1:15.203
3 +
+
+ NOR +
+
+8.102S1:16.044
4 +
+
+ HAM +
+
+11.723H1:15.991
5 +
+
+ RUS +
+
+14.088M1:16.388
6 +
+
+ PIA +
+
+18.430S1:16.701
7 +
+
+ SAI +
+
+22.115H1:16.924
8 +
+
+ ANT +
+
+27.660M1:17.204
9 +
+
+ ALO +
+
+35.291H1:17.450
10 +
+
+ GAS +
+
+42.880M1:17.692
11
OCO
+51.334H1:18.103
12
HUL
+55.771S1:18.320
13
STR
+1:04.2M1:18.890
14
TSU
+1 LAPH1:19.210
15
ZHO
DNFS
+
+ + +
+
Race Control
+
    +
  • + L47 +
    + SAFETY CAR DEPLOYED — Incident at Turn 10 (Bottas, Albon) +
  • +
  • + L47 +
    + PIT LANE OPEN during Safety Car period +
  • +
  • + L44 +
    + VER — 5 SECOND PENALTY · Causing a collision with NOR at T1 +
  • +
  • + L42 +
    + DRS ENABLED — Zones 1, 2 and 3 +
  • +
  • + L41 +
    + LEC — FASTEST LAP · 1:12.456 on Lap 41 +
  • +
  • + L38 +
    + RED FLAG — Track debris at Casino corner, extraction in progress +
  • +
  • + L38 +
    + RACE SUSPENDED · All cars to proceed to pit lane +
  • +
  • + L36 +
    + Stewards investigating incident between ALO and STR — Turn 6 +
  • +
  • + L31 +
    + VIRTUAL SAFETY CAR DEPLOYED — Bottas car recovered to pit lane +
  • +
  • + L33 +
    + VIRTUAL SAFETY CAR ENDING — Racing to resume next lap +
  • +
  • + L34 +
    + DRS ENABLED — Zones 1, 2 and 3 +
  • +
  • + L28 +
    + Weather: Track Temp 44°C / Air Temp 28°C / Humidity 62% +
  • +
+
+ + +
+
+ + +
+
+
+ LEC +
+ vs +
+
+ VER +
+ 3.456s + ↓ closing +
+ +
+
+
+ HAM +
+ vs +
+
+ RUS +
+ 2.365s + — stable +
+ +
+
+
+ PIA +
+ vs +
+
+ SAI +
+ 4.315s + ↓ closing +
+ +
+
+
+ ALO +
+ vs +
+
+ GAS +
+ 6.411s + ↑ opening +
+
+ + +
+ + +
+
+
+ VER + M + +24 +
+ OVERDUE +
+ +
+
+
+ HAM + H + +31 +
+ SOON +
+ +
+
+
+ RUS + M + +19 +
+ OPEN +
+ +
+
+
+ ALO + H + +38 +
+ OVERDUE +
+ +
+
+
+ LEC + M + +12 +
+ DONE +
+ +
+
+
+ NOR + S + +1 +
+ DONE +
+
+
+ +
+ + +
+ + + +
+ +
+ + + + diff --git a/documentations/refactor/screens/race-hub.html b/documentations/refactor/screens/race-hub.html new file mode 100644 index 0000000..7bd6558 --- /dev/null +++ b/documentations/refactor/screens/race-hub.html @@ -0,0 +1,847 @@ + + + + + + box-box — Race Hub · Monaco GP 2025 + + + + + + + +
+
+ 2025 + + Monaco GP + + Race +
+
+ 78 Laps · 260.286 km · 25 May 2025 + Local data +
+
+ + + + + +
+
+ + +
+ + +
+ + +
+ + + + + + +
+ + +
+ + +
+
+ Final Classification + 78 laps · 1:32:14.456 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PDriverTeamGridΔTime / GapFastest LapPts
1 +
+
+ LEC16 +
+
Ferrari3↑21:32:14.4561:14.89225
2 +
+
+ VER1 +
+
Red Bull1↓1+3.4561:15.02318
3 +
+
+ NOR4 +
+
McLaren4↑1+8.1231:14.756 ●15
4 +
+
+ PIA81 +
+
McLaren6↑2+12.3451:15.23412
5 +
+
+ RUS63 +
+
Mercedes2↓3+15.6781:15.45610
6 +
+
+ HAM44 +
+
Ferrari5↓1+21.2341:15.8908
7 +
+
+ ALO14 +
+
Aston Martin8↑1+28.4561:16.2346
8 +
+
+ SAI55 +
+
Williams7↓1+35.7891:16.5674
9 +
+
+ GAS10 +
+
Alpine9+42.1231:16.8902
10 +
+
+ OCO31 +
+
Haas10+48.4561:17.0121
+
+
+ + +
+
+ Tyre Strategy + 78 laps · SC L23–26 · VSC L58–60 +
+
+
+
Soft
+
Medium
+
Hard
+
+
+ SC / VSC period +
+
+
+ + +
+
+ Position Evolution + Top 6 · SC L23–26 · VSC L58–60 +
+
+
+
LEC
+
VER
+
NOR
+
PIA
+
RUS
+
HAM
+
+
+ + +
+
+ Race Control + 14 messages +
+
+
+ L1 + Start + RACE START — Track Clear +
+
+ L3 + DRS + DRS ENABLED +
+
+ L18 + Incident + ALB/STR at T10 under investigation +
+
+ L23 + Safety Car + SAFETY CAR DEPLOYED — Incident T10 (ALB retirement) +
+
+ L26 + Safety Car + SAFETY CAR IN THIS LAP +
+
+ L27 + DRS + DRS ENABLED — Lap 27 +
+
+ L35 + Penalty + 5-SECOND PENALTY — RUS · Unsafe release pit lane +
+
+ L38 + Fastest Lap + NOR · 1:14.756 +
+
+ L58 + VSC + VIRTUAL SAFETY CAR DEPLOYED — Debris T6 +
+
+ L60 + VSC + VIRTUAL SAFETY CAR ENDING +
+
+ L61 + DRS + DRS ENABLED — Lap 61 +
+
+ L78 + Finish + CHEQUERED FLAG — Race complete +
+
+
+ + +
+
+ Weather + Race · sampled every 5 laps · highlighted = SC/VSC +
+
+
LapAir °CTrack °CHumid %Wind km/hDirRain
+
122.432.1656NW0%
+
1023.134.2637NW0%
+
2023.835.6626N1%
+
2324.036.0627N1%
+
3024.336.8618NW2%
+
4024.937.4608NW2%
+
5025.238.1599W3%
+
5825.538.3598W3%
+
7025.838.6587W4%
+
7826.138.8577NW4%
+
+
+ + +
+
+ Dataset Status + session_key 9158 · Monaco GP Race +
+
+
session_results3h ago
+
starting_grid3h ago
+
laps3h ago
+
stints3h ago
+
pit_stops3h ago
+
positions3h ago
+
race_control3h ago
+
weather3h ago
+
overtakes3h ago
+
track_outlinecached
+
car_data_samplesnot ingested
+
location_samplesnot ingested
+
team_radionot ingested
+
+
+
# Ingest telemetry (high-volume, explicit only)
+
box-box --ingest-session 9158 --datasets car_data,location
+
+
# Refresh all datasets for this session
+
box-box --ingest-session 9158 --refresh
+
+
+ +
+
+ + + + +
+ + + + diff --git a/documentations/refactor/screens/styles.css b/documentations/refactor/screens/styles.css new file mode 100644 index 0000000..267dbec --- /dev/null +++ b/documentations/refactor/screens/styles.css @@ -0,0 +1,779 @@ +/* ================================================================ + box-box design system — F1 Ops Room + ================================================================ */ + +:root { + /* Backgrounds */ + --c-bg: #0a0a0a; + --c-surface: #111111; + --c-surface-2: #181818; + --c-surface-3: #202020; + + /* Borders */ + --c-border: #242424; + --c-border-2: #303030; + + /* Text */ + --c-text: #ebebeb; + --c-text-2: #9c9c9c; + --c-text-3: #6c6c6c; + --c-text-inv: #0a0a0a; + + /* Signal colors */ + --c-red: #e10600; + --c-yellow: #ffd600; + --c-green: #00cc6a; + --c-purple: #b06fff; + --c-blue: #4499ff; + --c-orange: #ff8833; + + /* Tyre compounds */ + --tyre-s: #e8002d; + --tyre-m: #c8b400; + --tyre-h: #b8b8b8; + --tyre-i: #39b54a; + --tyre-w: #0067ff; + + /* Team colors */ + --t-rb: #3671c6; + --t-mcl: #ff8000; + --t-fer: #e8002d; + --t-mer: #27f4d2; + --t-am: #229971; + --t-alp: #ff87bc; + --t-wil: #64c4ff; + --t-vcarb: #6692ff; + --t-haas: #b6babd; + --t-ks: #52e252; + + /* Spacing */ + --s1: 4px; + --s2: 8px; + --s3: 12px; + --s4: 16px; + --s5: 20px; + --s6: 24px; + --s8: 32px; + + /* Typography */ + --f-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; + --f-mono: 'SF Mono', ui-monospace, 'Fira Code', 'Courier New', monospace; + + /* Density — comfortable default */ + --row-h: 34px; + --pad-v: 6px; + --pad-h: 10px; + --sec-gap: 28px; +} + +html.compact { + --row-h: 26px; + --pad-v: 3px; + --pad-h: 8px; + --sec-gap: 18px; +} + +/* ── Reset ──────────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + font-size: 13px; + background: var(--c-bg); + color: var(--c-text); + scroll-behavior: smooth; +} + +body { + font-family: var(--f-ui); + line-height: 1.4; + min-height: 100vh; +} + +a { color: inherit; text-decoration: none; } +button { cursor: pointer; font-family: var(--f-ui); } + +/* ── App Nav ────────────────────────────────────────────────────── */ + +.app-nav { + display: flex; + align-items: center; + gap: var(--s2); + height: 44px; + padding: 0 var(--s5); + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + position: sticky; + top: 0; + z-index: 200; +} + +.nav-logo { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--c-text); + margin-right: var(--s3); +} + +.nav-logo em { + color: var(--c-red); + font-style: normal; +} + +.nav-links { + display: flex; + gap: 2px; + flex: 1; +} + +.nav-links a { + padding: 4px var(--s3); + border-radius: 3px; + font-size: 12px; + color: var(--c-text-3); + letter-spacing: 0.02em; + transition: color 0.1s, background 0.1s; +} + +.nav-links a:hover { color: var(--c-text-2); background: var(--c-surface-2); } +.nav-links a.active { color: var(--c-text); } + +.nav-right { + display: flex; + align-items: center; + gap: var(--s3); +} + +.live-badge { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--c-red); +} + +.live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--c-red); + animation: blink 1.4s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } +} + +.density-toggle { + display: flex; + gap: 2px; +} + +.density-toggle button { + padding: 2px 7px; + background: none; + border: 1px solid var(--c-border); + color: var(--c-text-3); + font-size: 10px; + border-radius: 2px; + letter-spacing: 0.04em; +} + +.density-toggle button.active { + background: var(--c-surface-2); + border-color: var(--c-border-2); + color: var(--c-text-2); +} + +/* ── Section Headers ────────────────────────────────────────────── */ + +.sec-header { + display: flex; + align-items: baseline; + gap: var(--s3); + padding-bottom: var(--s2); + border-bottom: 1px solid var(--c-border); + margin-bottom: var(--s4); +} + +.sec-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--c-text-2); +} + +.sec-meta { + font-size: 11px; + color: var(--c-text-3); + font-family: var(--f-mono); +} + +/* ── Data Table ─────────────────────────────────────────────────── */ + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.data-table th { + padding: var(--s1) var(--pad-h); + text-align: left; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--c-text-3); + border-bottom: 1px solid var(--c-border); + white-space: nowrap; +} + +.data-table td { + padding: var(--pad-v) var(--pad-h); + border-bottom: 1px solid var(--c-border); + height: var(--row-h); + white-space: nowrap; +} + +.data-table tbody tr:hover { background: var(--c-surface-2); } +.data-table tbody tr:last-child td { border-bottom: none; } +.data-table .num { font-family: var(--f-mono); text-align: right; } +.data-table .center { text-align: center; } + +/* ── Driver Identity Cell ───────────────────────────────────────── */ + +.drv-cell { + display: flex; + align-items: center; + gap: var(--s2); +} + +.drv-bar { + width: 3px; + height: 20px; + border-radius: 1px; + flex-shrink: 0; +} + +.drv-code { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; +} + +.drv-num { + font-family: var(--f-mono); + font-size: 10px; + color: var(--c-text-3); +} + +/* ── Tyre Badge ─────────────────────────────────────────────────── */ + +.tyre { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 9px; + font-weight: 800; + font-family: var(--f-mono); + flex-shrink: 0; +} + +.tyre.S { background: var(--tyre-s); color: #fff; } +.tyre.M { background: var(--tyre-m); color: #000; } +.tyre.H { background: var(--tyre-h); color: #333; } +.tyre.I { background: var(--tyre-i); color: #fff; } +.tyre.W { background: var(--tyre-w); color: #fff; } + +/* ── Status Dot ─────────────────────────────────────────────────── */ + +.dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.dot.live { background: var(--c-red); } +.dot.local { background: var(--c-green); } +.dot.partial { background: var(--c-yellow); } +.dot.missing { background: var(--c-text-3); } +.dot.stale { background: var(--c-orange); } +.dot.upcoming{ background: var(--c-border-2); } + +/* ── Status Badge (inline) ──────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 2px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; +} + +.badge.local { background: rgba(0,204,106,0.12); color: var(--c-green); } +.badge.live { background: rgba(225,6,0,0.12); color: var(--c-red); } +.badge.partial { background: rgba(255,214,0,0.12); color: var(--c-yellow); } +.badge.missing { background: rgba(85,85,85,0.12); color: var(--c-text-3); } +.badge.stale { background: rgba(255,136,51,0.12); color: var(--c-orange); } + +/* ── Source Strip ───────────────────────────────────────────────── */ + +.source-strip { + display: flex; + align-items: center; + gap: var(--s3); + padding: var(--s2) var(--s4); + background: var(--c-surface); + border: 1px solid var(--c-border); + font-size: 11px; + color: var(--c-text-2); +} + +/* ── Countdown ──────────────────────────────────────────────────── */ + +.countdown { + display: flex; + align-items: baseline; + gap: var(--s3); +} + +.cd-unit { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.cd-num { + font-family: var(--f-mono); + font-size: 36px; + font-weight: 700; + color: var(--c-text); + line-height: 1; +} + +.cd-label { + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--c-text-3); +} + +.cd-sep { + font-family: var(--f-mono); + font-size: 30px; + color: var(--c-border-2); + padding-bottom: 4px; +} + +/* ── Session Banner (Live Timing) ───────────────────────────────── */ + +.session-banner { + display: flex; + align-items: center; + gap: var(--s5); + height: 40px; + padding: 0 var(--s5); + background: var(--c-surface); + border-bottom: 2px solid var(--c-border); + font-family: var(--f-mono); + font-size: 12px; + overflow-x: auto; + overflow-y: hidden; + flex-shrink: 0; +} + +.banner-item { + display: flex; + align-items: center; + gap: var(--s2); + flex-shrink: 0; +} + +.banner-label { + font-size: 9px; + font-family: var(--f-ui); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--c-text-3); +} + +.banner-val { font-weight: 600; } + +.banner-sep { color: var(--c-border-2); padding: 0 var(--s1); } + +.track-green { color: var(--c-green); } +.track-yellow { color: var(--c-yellow); } +.track-red { color: var(--c-red); } + +/* ── Race Control Messages ──────────────────────────────────────── */ + +.rc-list { display: flex; flex-direction: column; } + +.rc-msg { + display: grid; + grid-template-columns: 56px 1fr; + gap: var(--s2); + padding: 6px var(--s3); + border-bottom: 1px solid var(--c-border); + font-size: 11px; + line-height: 1.4; +} + +.rc-msg:last-child { border-bottom: none; } + +.rc-lap { font-family: var(--f-mono); color: var(--c-text-3); padding-top: 1px; } +.rc-text { color: var(--c-text-2); } + +.rc-msg.sc .rc-text { color: var(--c-yellow); font-weight: 600; } +.rc-msg.vsc .rc-text { color: var(--c-yellow); } +.rc-msg.drs .rc-text { color: var(--c-green); } +.rc-msg.flag .rc-text { color: var(--c-red); font-weight: 600; } +.rc-msg.fl .rc-text { color: var(--c-purple); } + +/* ── Position Indicators ────────────────────────────────────────── */ + +.pos-gain { color: var(--c-green); font-weight: 600; } +.pos-loss { color: var(--c-red); } +.pos-same { color: var(--c-text-3); } + +/* ── Timing Tower specific ──────────────────────────────────────── */ + +.timing-table .pos-col { width: 30px; } +.timing-table .drv-col { min-width: 120px; } +.timing-table .gap-col { width: 80px; } +.timing-table .int-col { width: 70px; } +.timing-table .tyre-col { width: 40px; text-align: center; } +.timing-table .age-col { width: 36px; } +.timing-table .last-col { width: 78px; } +.timing-table .best-col { width: 78px; } + +.timing-table tr.fastest-lap td { background: rgba(176,111,255,0.06); } +.timing-table tr.pit-in td { background: rgba(255,214,0,0.05); } +.timing-table tr.dnf td { opacity: 0.45; } + +/* ── Section Tabs ───────────────────────────────────────────────── */ + +.section-tabs { + display: flex; + gap: 2px; + padding: var(--s3) var(--s5); + border-bottom: 1px solid var(--c-border); + overflow-x: auto; +} + +.tab-btn { + padding: 4px var(--s3); + background: none; + border: 1px solid transparent; + border-radius: 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--c-text-3); + white-space: nowrap; + transition: color 0.1s; +} + +.tab-btn:hover { color: var(--c-text-2); } +.tab-btn.active { color: var(--c-text); border-color: var(--c-border-2); background: var(--c-surface-2); } + +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* ── Panels / Boxes ─────────────────────────────────────────────── */ + +.panel { + background: var(--c-surface); + border: 1px solid var(--c-border); +} + +/* ── Strategy Chart Container ───────────────────────────────────── */ + +.strategy-chart { width: 100%; overflow: hidden; } +.strategy-chart svg { display: block; width: 100%; height: auto; } + +/* ── CLI Block ──────────────────────────────────────────────────── */ + +.cli-block { + background: var(--c-surface-2); + border: 1px solid var(--c-border); + border-left: 3px solid var(--c-border-2); + padding: var(--s3) var(--s4); + font-family: var(--f-mono); + font-size: 11px; + color: var(--c-text-2); + line-height: 1.8; +} + +.cli-block .comment { color: var(--c-text-3); } +.cli-block .cmd { color: var(--c-green); } + +/* ── Dataset Status Row ─────────────────────────────────────────── */ + +.dataset-row { + display: flex; + align-items: center; + gap: var(--s3); + padding: var(--pad-v) 0; + border-bottom: 1px solid var(--c-border); + font-size: 11px; +} + +.dataset-row:last-child { border-bottom: none; } + +.ds-name { flex: 1; color: var(--c-text-2); } +.ds-status { font-family: var(--f-mono); font-size: 10px; } +.ds-time { font-family: var(--f-mono); font-size: 10px; color: var(--c-text-3); min-width: 80px; text-align: right; } + +/* ── Utilities ──────────────────────────────────────────────────── */ + +.mono { font-family: var(--f-mono); } +.t2 { color: var(--c-text-2); } +.t3 { color: var(--c-text-3); } +.t-green { color: var(--c-green); } +.t-red { color: var(--c-red); } +.t-yellow { color: var(--c-yellow); } +.t-purple { color: var(--c-purple); } +.t-orange { color: var(--c-orange); } +.t-blue { color: var(--c-blue); } + +.flex { display: flex; } +.flex-col { display: flex; flex-direction: column; } +.ai-c { align-items: center; } +.gap-1 { gap: var(--s1); } +.gap-2 { gap: var(--s2); } +.gap-3 { gap: var(--s3); } +.gap-4 { gap: var(--s4); } + +.divider { height: 1px; background: var(--c-border); margin: var(--sec-gap) 0; } + +.scroll-y { overflow-y: auto; } +.scroll-x { overflow-x: auto; } + +/* ── Responsive ─────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .nav-links a { font-size: 11px; padding: 4px var(--s2); } + .hide-mobile { display: none !important; } +} + +@media (max-width: 480px) { + .nav-links { gap: 0; } + .hide-sm { display: none !important; } +} + +/* ── Podium row accents ──────────────────────────────────────────── */ + +.pos-p1 { color: #d4a93a; font-size: 15px; font-weight: 800; } +.pos-p2 { color: #a8a8a8; font-size: 14px; font-weight: 700; } +.pos-p3 { color: #b07840; font-size: 14px; font-weight: 700; } + +/* ── Race Hub 2-column body ─────────────────────────────────────── */ + +.rh-body { + display: grid; + grid-template-columns: 1fr 290px; + align-items: start; + min-height: calc(100vh - 88px); +} + +.rh-aside { + border-left: 1px solid var(--c-border); + position: sticky; + top: 44px; + max-height: calc(100vh - 44px); + overflow-y: auto; + padding: var(--s4); + display: flex; + flex-direction: column; + gap: var(--s5); +} + +@media (max-width: 980px) { + .rh-body { grid-template-columns: 1fr; } + .rh-aside { + position: static; + max-height: none; + border-left: none; + border-top: 2px solid var(--c-border); + padding: var(--s5); + } +} + +/* ── Race Summary (aside) ────────────────────────────────────────── */ + +.podium-list { display: flex; flex-direction: column; gap: 1px; } + +.podium-item { + display: grid; + grid-template-columns: 22px 1fr auto; + align-items: center; + gap: var(--s2); + padding: 6px 0; + border-bottom: 1px solid var(--c-border); +} + +.podium-item:last-child { border-bottom: none; } +.podium-p { font-family: var(--f-mono); font-size: 12px; font-weight: 700; color: var(--c-text-3); } +.podium-gap-val { font-family: var(--f-mono); font-size: 11px; color: var(--c-text-2); } + +.summary-stat-row { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid var(--c-border); + font-size: 11px; +} + +.summary-stat-row:last-child { border-bottom: none; } +.sstat-label { color: var(--c-text-3); } +.sstat-val { font-family: var(--f-mono); color: var(--c-text-2); font-size: 11px; } + +/* ── Pit Window ─────────────────────────────────────────────────── */ + +.pit-row { + display: flex; + align-items: center; + gap: var(--s2); + padding: 5px var(--s3); + border-bottom: 1px solid var(--c-border); + font-size: 11px; +} + +.pit-row:last-child { border-bottom: none; } +.pit-driver { flex: 1; } +.pit-tyre-age { font-family: var(--f-mono); font-size: 11px; color: var(--c-text-2); min-width: 32px; text-align: right; } + +.pit-status { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + min-width: 56px; + text-align: right; +} + +.pit-status.open { color: var(--c-green); } +.pit-status.soon { color: var(--c-yellow); } +.pit-status.overdue { color: var(--c-orange); } +.pit-status.done { color: var(--c-text-3); } + +/* ── Pinned Driver Cards ─────────────────────────────────────────── */ + +.pinned-strip { + display: flex; + gap: var(--s2); + padding: var(--s2) var(--s4); + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + overflow-x: auto; + flex-shrink: 0; +} + +.pin-card { + display: flex; + align-items: center; + gap: var(--s2); + padding: 5px var(--s3); + background: var(--c-surface-2); + border: 1px solid var(--c-border); + border-radius: 2px; + flex-shrink: 0; +} + +.pin-pos { font-family: var(--f-mono); font-size: 11px; color: var(--c-text-3); min-width: 16px; } +.pin-gap { font-family: var(--f-mono); font-size: 11px; color: var(--c-text-2); } + +/* ── Phone bottom tab bar ────────────────────────────────────────── */ + +.phone-tab-bar { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 54px; + background: var(--c-surface); + border-top: 2px solid var(--c-border); + z-index: 300; +} + +.phone-tab-bar button { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background: none; + border: none; + color: var(--c-text-3); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + padding-bottom: 4px; + transition: color 0.1s; +} + +.phone-tab-bar button.active { color: var(--c-text); } +.phone-tab-bar button .tab-icon { font-size: 17px; line-height: 1; } + +/* Phone panel system */ +.lt-phone-panel { } + +@media (max-width: 768px) { + .phone-tab-bar { display: flex; } + .has-phone-tabs { + padding-bottom: 58px; + overflow: hidden; + height: calc(100vh - 44px - 40px); + } + .lt-phone-panel { display: none !important; } + .lt-phone-panel.phone-active { display: flex !important; } + /* Override grid for phone */ + .lt-body-phone { display: block !important; } +} + +/* ── Responsive: iPad intermediate ─────────────────────────────── */ + +@media (min-width: 769px) and (max-width: 1024px) { + .rh-body { grid-template-columns: 1fr 240px; } +} + +/* ── Gap sparkline ──────────────────────────────────────────────── */ + +.gap-spark { + display: inline-flex; + align-items: flex-end; + gap: 1px; + height: 12px; + margin-left: 4px; + vertical-align: middle; + opacity: 0.7; +} + +.gap-spark span { + width: 3px; + background: currentColor; + border-radius: 1px; +} diff --git a/internal/api/client.go b/internal/api/client.go index 976e139..712d8a9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -38,6 +38,11 @@ func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *O } } +// BaseURL returns the configured OpenF1 API root URL. +func (c *OpenF1Client) BaseURL() string { + return c.url +} + // Cache returns the underlying Cache so callers can access track outline // storage and other persistent data directly. func (c *OpenF1Client) Cache() *Cache { diff --git a/internal/api/openf1.go b/internal/api/openf1.go index 6f4c85f..21dbc53 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -89,6 +89,53 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil } +// FetchStrict performs a GET using the HTTP cache for fresh entries only. +// Unlike get(), it never falls back to expired cache on failure — intended +// for ingestion workflows that need authoritative responses or explicit errors. +func (c *OpenF1Client) FetchStrict(url string) ([]byte, error) { + if cachedData, ok := c.cache.Get(url); ok { + return cachedData, nil + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + var apiErr struct { + Detail string `json:"detail"` + } + if json.Unmarshal(data, &apiErr) == nil && apiErr.Detail != "" { + detail := strings.ToLower(apiErr.Detail) + if strings.Contains(detail, "live") && strings.Contains(detail, "session") { + return nil, fmt.Errorf("%w", ErrLiveSessionLocked) + } + return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail) + } + } + return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url) + } + + _ = c.cache.Set(url, data) + return data, nil +} + // tryStale attempts to return stale cached data when a live request has failed. // If stale data exists it sets the client's stale flag and returns the data. // Otherwise it returns the original error unchanged so callers can handle it. diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go new file mode 100644 index 0000000..d20b290 --- /dev/null +++ b/internal/ingest/ingest.go @@ -0,0 +1,471 @@ +package ingest + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/store" +) + +// Options configures ingestion behavior. +type Options struct { + DryRun bool + RequestDelay time.Duration + MaxRetries int + RetryBackoff time.Duration + Progress *Progress +} + +// DefaultOptions returns conservative ingestion defaults. +func DefaultOptions() Options { + return Options{ + RequestDelay: 300 * time.Millisecond, + MaxRetries: 3, + RetryBackoff: 500 * time.Millisecond, + Progress: NewProgress(nil), + } +} + +// Summary captures the outcome of an ingestion run. +type Summary struct { + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + Status string `json:"status"` + DryRun bool `json:"dry_run"` + Meetings int `json:"meetings"` + Sessions int `json:"sessions"` + Drivers int `json:"drivers"` + SessionResults int `json:"session_results"` + StartingGrid int `json:"starting_grid"` + RawPayloads int `json:"raw_payloads"` + RawInserted int `json:"raw_inserted"` + Errors []string `json:"errors,omitempty"` +} + +// Service orchestrates OpenF1-to-store ingestion workflows. +type Service struct { + store *store.Store + source Source + opts Options +} + +// NewService creates an ingestion service. +func NewService(st *store.Store, source Source, opts Options) *Service { + if opts.MaxRetries <= 0 { + opts.MaxRetries = 3 + } + if opts.RetryBackoff <= 0 { + opts.RetryBackoff = 500 * time.Millisecond + } + if opts.RequestDelay <= 0 { + opts.RequestDelay = 300 * time.Millisecond + } + if opts.Progress == nil { + opts.Progress = NewProgress(nil) + } + return &Service{store: st, source: source, opts: opts} +} + +// IngestYear fetches and stores all meetings for a season year. +func (s *Service) IngestYear(year int) (Summary, error) { + summary := Summary{ + ScopeType: "year", + ScopeKey: fmt.Sprintf("%d", year), + DryRun: s.opts.DryRun, + } + if year < 2023 { + return summary, fmt.Errorf("invalid year %d: must be 2023 or later", year) + } + + runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey) + if err != nil { + return summary, err + } + + s.opts.Progress.Step("fetching meetings for %d", year) + fetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) { + return s.source.FetchMeetingsForYear(year) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + + summary.RawPayloads++ + if !s.opts.DryRun { + inserted, err := s.storeRaw(fetch, nil, nil) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + } + s.delay() + + for _, m := range meetings { + if s.opts.DryRun { + summary.Meetings++ + continue + } + if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.Meetings++ + } + + summary.Status = statusForDryRun(s.opts.DryRun) + s.finishRun(runID, summary) + s.opts.Progress.Summary(summary) + return summary, nil +} + +// IngestMeeting fetches meeting metadata and all sessions for a meeting key. +func (s *Service) IngestMeeting(meetingKey int) (Summary, error) { + summary := Summary{ + ScopeType: "meeting", + ScopeKey: fmt.Sprintf("%d", meetingKey), + DryRun: s.opts.DryRun, + } + + runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey) + if err != nil { + return summary, err + } + + s.opts.Progress.Step("fetching meeting %d", meetingKey) + meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) { + return s.source.FetchMeetingsForMeetingKey(meetingKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + summary.RawPayloads++ + if !s.opts.DryRun { + mk := meetingKey + inserted, err := s.storeRaw(meetingFetch, &mk, nil) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + } + s.delay() + + for _, m := range meetings { + if s.opts.DryRun { + summary.Meetings++ + continue + } + if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.Meetings++ + } + + s.opts.Progress.Step("fetching sessions for meeting %d", meetingKey) + sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) { + return s.source.FetchSessionsForMeeting(meetingKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + summary.RawPayloads++ + if !s.opts.DryRun { + mk := meetingKey + inserted, err := s.storeRaw(sessionFetch, &mk, nil) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + } + s.delay() + + for _, sess := range sessions { + if s.opts.DryRun { + summary.Sessions++ + continue + } + if err := s.store.UpsertSession(sessionToStore(sess)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.Sessions++ + } + + summary.Status = statusForDryRun(s.opts.DryRun) + s.finishRun(runID, summary) + s.opts.Progress.Summary(summary) + return summary, nil +} + +// IngestSession ingests Race Hub v1 datasets for a single session. +func (s *Service) IngestSession(sessionKey int) (Summary, error) { + summary := Summary{ + ScopeType: "session", + ScopeKey: fmt.Sprintf("%d", sessionKey), + DryRun: s.opts.DryRun, + } + + runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey) + if err != nil { + return summary, err + } + + s.opts.Progress.Step("fetching session %d", sessionKey) + sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) { + return s.source.FetchSessionsForSessionKey(sessionKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if len(sessions) == 0 { + err := fmt.Errorf("session %d not found", sessionKey) + return s.finishFailed(runID, summary, err) + } + sess := sessions[0] + meetingKey := sess.MeetingKey + sk := sessionKey + + summary.RawPayloads++ + if !s.opts.DryRun { + inserted, err := s.storeRaw(sessionFetch, &meetingKey, &sk) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + } + s.delay() + + s.opts.Progress.Step("fetching meeting %d for session context", meetingKey) + meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) { + return s.source.FetchMeetingsForMeetingKey(meetingKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + summary.RawPayloads++ + if !s.opts.DryRun { + inserted, err := s.storeRaw(meetingFetch, &meetingKey, &sk) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + for _, m := range meetings { + if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.Meetings++ + } + } else { + summary.Meetings = len(meetings) + } + s.delay() + + if s.opts.DryRun { + summary.Sessions++ + } else { + if err := s.store.UpsertSession(sessionToStore(sess)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.Sessions++ + } + + s.opts.Progress.Step("fetching drivers for session %d", sessionKey) + driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) { + return s.source.FetchDriversForSession(sessionKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + summary.RawPayloads++ + if !s.opts.DryRun { + inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + for _, d := range drivers { + if err := s.store.UpsertDriver(driverToStore(d)); err != nil { + return s.finishFailed(runID, summary, err) + } + if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.Drivers++ + } + } else { + summary.Drivers = len(drivers) + } + s.delay() + + s.opts.Progress.Step("fetching session results for session %d", sessionKey) + resultFetch, results, err := fetchWithRetry(s, func() (FetchResult, []models.SessionResult, error) { + return s.source.FetchSessionResult(sessionKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + summary.RawPayloads++ + if !s.opts.DryRun { + inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + for _, r := range results { + if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.SessionResults++ + } + } else { + summary.SessionResults = len(results) + } + s.delay() + + s.opts.Progress.Step("fetching starting grid for session %d", sessionKey) + gridFetch, grid, err := fetchWithRetry(s, func() (FetchResult, []models.StartingGrid, error) { + return s.source.FetchStartingGrid(sessionKey) + }) + if err != nil { + return s.finishFailed(runID, summary, err) + } + summary.RawPayloads++ + if !s.opts.DryRun { + inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk) + if err != nil { + return s.finishFailed(runID, summary, err) + } + if inserted { + summary.RawInserted++ + } + for _, g := range grid { + if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil { + return s.finishFailed(runID, summary, err) + } + summary.StartingGrid++ + } + } else { + summary.StartingGrid = len(grid) + } + + summary.Status = statusForDryRun(s.opts.DryRun) + s.finishRun(runID, summary) + s.opts.Progress.Summary(summary) + return summary, nil +} + +func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) { + if s.opts.DryRun { + return 0, nil + } + return s.store.CreateIngestionRun(scopeType, scopeKey, false) +} + +func (s *Service) finishRun(runID int64, summary Summary) { + if s.opts.DryRun || runID == 0 { + return + } + b, _ := json.Marshal(summary) + _ = s.store.FinishIngestionRun(runID, summary.Status, string(b)) +} + +func (s *Service) finishFailed(runID int64, summary Summary, err error) (Summary, error) { + summary.Status = "failed" + summary.Errors = append(summary.Errors, err.Error()) + if runID != 0 && !s.opts.DryRun { + b, _ := json.Marshal(summary) + _ = s.store.FinishIngestionRun(runID, summary.Status, string(b)) + } + s.opts.Progress.Summary(summary) + return summary, err +} + +func (s *Service) storeRaw(fetch FetchResult, meetingKey, sessionKey *int) (bool, error) { + _, inserted, err := s.store.InsertRawPayload(store.RawPayload{ + Source: sourceOpenF1, + Endpoint: fetch.Endpoint, + RequestKey: fetch.RequestKey, + MeetingKey: meetingKey, + SessionKey: sessionKey, + Payload: string(fetch.Body), + FetchedAt: fetch.FetchedAt, + ProvenanceJSON: provenanceJSON(fetch), + }) + return inserted, err +} + +func (s *Service) delay() { + if s.opts.RequestDelay > 0 { + time.Sleep(s.opts.RequestDelay) + } +} + +func statusForDryRun(dryRun bool) string { + if dryRun { + return "dry_run" + } + return "completed" +} + +type fetchFunc[T any] func() (FetchResult, T, error) + +func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) { + var zero T + var lastErr error + + for attempt := 0; attempt < s.opts.MaxRetries; attempt++ { + if attempt > 0 { + time.Sleep(s.opts.RetryBackoff * time.Duration(attempt)) + } + + fetch, data, err := fn() + if err == nil { + return fetch, data, nil + } + lastErr = err + + if api.IsLiveSessionError(err) { + return FetchResult{}, zero, err + } + if !isRetryable(err) { + return FetchResult{}, zero, err + } + } + + return FetchResult{}, zero, lastErr +} + +func isRetryable(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "status 429") || + strings.Contains(msg, "status 5") || + strings.Contains(msg, "timeout") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "temporary") { + return true + } + var netErr interface{ Timeout() bool } + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + return false +} diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go new file mode 100644 index 0000000..44dfeb9 --- /dev/null +++ b/internal/ingest/ingest_test.go @@ -0,0 +1,381 @@ +package ingest + +import ( + "encoding/json" + "errors" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/store" +) + +type fakeSource struct { + meetingsByYear map[int][]models.Meeting + meetingsByKey map[int][]models.Meeting + sessionsByMeeting map[int][]models.Session + sessionsByKey map[int][]models.Session + drivers map[int][]models.Driver + results map[int][]models.SessionResult + grid map[int][]models.StartingGrid + failOn string + liveLockout bool +} + +func newFakeSource() *fakeSource { + return &fakeSource{ + meetingsByYear: make(map[int][]models.Meeting), + meetingsByKey: make(map[int][]models.Meeting), + sessionsByMeeting: make(map[int][]models.Session), + sessionsByKey: make(map[int][]models.Session), + drivers: make(map[int][]models.Driver), + results: make(map[int][]models.SessionResult), + grid: make(map[int][]models.StartingGrid), + } +} + +func (f *fakeSource) maybeFail(endpoint string) error { + if f.liveLockout { + return fmt.Errorf("%w", api.ErrLiveSessionLocked) + } + if f.failOn == endpoint { + return errors.New("simulated fetch failure") + } + return nil +} + +func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult { + body, _ := json.Marshal(payload) + return FetchResult{ + Endpoint: endpoint, + RequestKey: requestKey, + URL: "fake://" + endpoint + "?" + requestKey, + Body: body, + FetchedAt: time.Now(), + } +} + +func (f *fakeSource) FetchMeetingsForYear(year int) (FetchResult, []models.Meeting, error) { + if err := f.maybeFail("meetings"); err != nil { + return FetchResult{}, nil, err + } + data := f.meetingsByYear[year] + return f.wrap("meetings", fmt.Sprintf("year=%d", year), data), data, nil +} + +func (f *fakeSource) FetchMeetingsForMeetingKey(meetingKey int) (FetchResult, []models.Meeting, error) { + if err := f.maybeFail("meetings"); err != nil { + return FetchResult{}, nil, err + } + data := f.meetingsByKey[meetingKey] + return f.wrap("meetings", fmt.Sprintf("meeting_key=%d", meetingKey), data), data, nil +} + +func (f *fakeSource) FetchSessionsForMeeting(meetingKey int) (FetchResult, []models.Session, error) { + if err := f.maybeFail("sessions"); err != nil { + return FetchResult{}, nil, err + } + data := f.sessionsByMeeting[meetingKey] + return f.wrap("sessions", fmt.Sprintf("meeting_key=%d", meetingKey), data), data, nil +} + +func (f *fakeSource) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []models.Session, error) { + if err := f.maybeFail("sessions"); err != nil { + return FetchResult{}, nil, err + } + data := f.sessionsByKey[sessionKey] + return f.wrap("sessions", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil +} + +func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) { + if err := f.maybeFail("drivers"); err != nil { + return FetchResult{}, nil, err + } + data := f.drivers[sessionKey] + return f.wrap("drivers", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil +} + +func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) { + if err := f.maybeFail("session_result"); err != nil { + return FetchResult{}, nil, err + } + data := f.results[sessionKey] + return f.wrap("session_result", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil +} + +func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) { + if err := f.maybeFail("starting_grid"); err != nil { + return FetchResult{}, nil, err + } + data := f.grid[sessionKey] + return f.wrap("starting_grid", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil +} + +func openTestStore(t *testing.T) *store.Store { + t.Helper() + path := filepath.Join(t.TempDir(), "ingest.db") + s, err := store.Open(path) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func testSessionFixtures() (int, int, *fakeSource) { + const meetingKey = 1229 + const sessionKey = 9472 + + src := newFakeSource() + src.meetingsByKey[meetingKey] = []models.Meeting{{ + MeetingKey: meetingKey, + MeetingName: "Monaco", + MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025", + Location: "Monaco", + CountryCode: "MON", + CountryName: "Monaco", + Circuit: models.Circuit{ + CircuitKey: 10, + CircuitShortName: "Monte Carlo", + }, + Year: 2025, + }} + src.sessionsByKey[sessionKey] = []models.Session{{ + SessionKey: sessionKey, + MeetingKey: meetingKey, + SessionName: "Race", + SessionType: "Race", + CircuitKey: 10, + }} + src.drivers[sessionKey] = []models.Driver{ + { + DriverNumber: 1, + FullName: "Max Verstappen", + SessionKey: sessionKey, + MeetingKey: meetingKey, + TeamName: "Red Bull Racing", + TeamColour: "3671C6", + }, + { + DriverNumber: 44, + FullName: "Lewis Hamilton", + SessionKey: sessionKey, + MeetingKey: meetingKey, + TeamName: "Ferrari", + TeamColour: "E8002D", + }, + } + src.results[sessionKey] = []models.SessionResult{ + {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, Points: 25, NumberOfLaps: 78}, + {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, Points: 18, NumberOfLaps: 78, GapToLeader: 1.5}, + } + src.grid[sessionKey] = []models.StartingGrid{ + {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.234}, + {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.456}, + } + + return meetingKey, sessionKey, src +} + +func TestIngestSessionWritesDomainAndRawRows(t *testing.T) { + _, sessionKey, src := testSessionFixtures() + st := openTestStore(t) + + opts := DefaultOptions() + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + summary, err := svc.IngestSession(sessionKey) + if err != nil { + t.Fatalf("IngestSession() error = %v", err) + } + if summary.Status != "completed" { + t.Fatalf("summary.Status = %q, want completed", summary.Status) + } + if summary.Drivers != 2 || summary.SessionResults != 2 || summary.StartingGrid != 2 { + t.Fatalf("summary counts = %+v, want 2 drivers/results/grid", summary) + } + if summary.RawPayloads != 5 { + t.Fatalf("summary.RawPayloads = %d, want 5", summary.RawPayloads) + } + + drivers, err := st.ListSessionDrivers(sessionKey) + if err != nil { + t.Fatalf("ListSessionDrivers() error = %v", err) + } + if len(drivers) != 2 { + t.Fatalf("session drivers = %d, want 2", len(drivers)) + } + + results, err := st.ListSessionResults(sessionKey) + if err != nil { + t.Fatalf("ListSessionResults() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("session results = %d, want 2", len(results)) + } + + grid, err := st.ListStartingGrid(sessionKey) + if err != nil { + t.Fatalf("ListStartingGrid() error = %v", err) + } + if len(grid) != 2 { + t.Fatalf("starting grid = %d, want 2", len(grid)) + } + + raw, err := st.ListRawPayloadsBySession(sessionKey) + if err != nil { + t.Fatalf("ListRawPayloadsBySession() error = %v", err) + } + if len(raw) != 5 { + t.Fatalf("raw payloads = %d, want 5", len(raw)) + } +} + +func TestIngestSessionIsIdempotent(t *testing.T) { + _, sessionKey, src := testSessionFixtures() + st := openTestStore(t) + + opts := DefaultOptions() + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + if _, err := svc.IngestSession(sessionKey); err != nil { + t.Fatalf("first IngestSession() error = %v", err) + } + if _, err := svc.IngestSession(sessionKey); err != nil { + t.Fatalf("second IngestSession() error = %v", err) + } + + count := func(query string) int { + var n int + if err := st.DB().QueryRow(query, sessionKey).Scan(&n); err != nil { + t.Fatalf("count query failed: %v", err) + } + return n + } + + if got := count(`SELECT COUNT(*) FROM session_drivers WHERE session_key = ?`); got != 2 { + t.Fatalf("session_drivers count = %d, want 2", got) + } + if got := count(`SELECT COUNT(*) FROM session_results WHERE session_key = ?`); got != 2 { + t.Fatalf("session_results count = %d, want 2", got) + } + if got := count(`SELECT COUNT(*) FROM starting_grid WHERE session_key = ?`); got != 2 { + t.Fatalf("starting_grid count = %d, want 2", got) + } +} + +func TestDryRunDoesNotWriteDomainRows(t *testing.T) { + _, sessionKey, src := testSessionFixtures() + st := openTestStore(t) + + opts := DefaultOptions() + opts.DryRun = true + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + summary, err := svc.IngestSession(sessionKey) + if err != nil { + t.Fatalf("IngestSession() error = %v", err) + } + if summary.Status != "dry_run" { + t.Fatalf("summary.Status = %q, want dry_run", summary.Status) + } + + var driverCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM drivers`).Scan(&driverCount); err != nil { + t.Fatalf("count drivers: %v", err) + } + if driverCount != 0 { + t.Fatalf("drivers written during dry-run = %d, want 0", driverCount) + } + + var rawCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM raw_payloads`).Scan(&rawCount); err != nil { + t.Fatalf("count raw payloads: %v", err) + } + if rawCount != 0 { + t.Fatalf("raw payloads written during dry-run = %d, want 0", rawCount) + } +} + +func TestSourceErrorStopsRun(t *testing.T) { + _, sessionKey, src := testSessionFixtures() + src.failOn = "session_result" + st := openTestStore(t) + + opts := DefaultOptions() + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + summary, err := svc.IngestSession(sessionKey) + if err == nil { + t.Fatal("IngestSession() expected error, got nil") + } + if summary.Status != "failed" { + t.Fatalf("summary.Status = %q, want failed", summary.Status) + } + + var resultCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM session_results WHERE session_key = ?`, sessionKey).Scan(&resultCount); err != nil { + t.Fatalf("count session_results: %v", err) + } + if resultCount != 0 { + t.Fatalf("session_results after failure = %d, want 0", resultCount) + } +} + +func TestLiveSessionLockoutSurfacesControlledFailure(t *testing.T) { + _, sessionKey, src := testSessionFixtures() + src.liveLockout = true + st := openTestStore(t) + + opts := DefaultOptions() + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + _, err := svc.IngestSession(sessionKey) + if err == nil { + t.Fatal("IngestSession() expected live lockout error, got nil") + } + if !api.IsLiveSessionError(err) { + t.Fatalf("error = %v, want live session lockout", err) + } +} + +func TestIngestYearAndMeeting(t *testing.T) { + src := newFakeSource() + src.meetingsByYear[2025] = []models.Meeting{ + {MeetingKey: 100, MeetingName: "Bahrain", Year: 2025}, + {MeetingKey: 101, MeetingName: "Saudi Arabia", Year: 2025}, + } + src.meetingsByKey[100] = src.meetingsByYear[2025][:1] + src.sessionsByMeeting[100] = []models.Session{ + {SessionKey: 9001, MeetingKey: 100, SessionName: "Race", SessionType: "Race"}, + } + + st := openTestStore(t) + opts := DefaultOptions() + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + yearSummary, err := svc.IngestYear(2025) + if err != nil { + t.Fatalf("IngestYear() error = %v", err) + } + if yearSummary.Meetings != 2 { + t.Fatalf("year meetings = %d, want 2", yearSummary.Meetings) + } + + meetingSummary, err := svc.IngestMeeting(100) + if err != nil { + t.Fatalf("IngestMeeting() error = %v", err) + } + if meetingSummary.Sessions != 1 { + t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions) + } +} diff --git a/internal/ingest/openf1.go b/internal/ingest/openf1.go new file mode 100644 index 0000000..a339a43 --- /dev/null +++ b/internal/ingest/openf1.go @@ -0,0 +1,270 @@ +package ingest + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/store" +) + +const sourceOpenF1 = "openf1" + +// FetchResult holds a fetched OpenF1 endpoint response with provenance metadata. +type FetchResult struct { + Endpoint string + RequestKey string + URL string + Body []byte + FetchedAt time.Time +} + +// Source fetches OpenF1 REST data for ingestion workflows. +type Source interface { + FetchMeetingsForYear(year int) (FetchResult, []models.Meeting, error) + FetchMeetingsForMeetingKey(meetingKey int) (FetchResult, []models.Meeting, error) + FetchSessionsForMeeting(meetingKey int) (FetchResult, []models.Session, error) + FetchSessionsForSessionKey(sessionKey int) (FetchResult, []models.Session, error) + FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) + FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) + FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) +} + +// OpenF1Source adapts OpenF1Client for ingestion using strict fetches. +type OpenF1Source struct { + client *api.OpenF1Client +} + +// NewOpenF1Source returns a Source backed by the OpenF1 API client. +func NewOpenF1Source(client *api.OpenF1Client) *OpenF1Source { + return &OpenF1Source{client: client} +} + +func (s *OpenF1Source) FetchMeetingsForYear(year int) (FetchResult, []models.Meeting, error) { + url := fmt.Sprintf("%s/v1/meetings?year=%d", s.client.BaseURL(), year) + return s.fetchMeetings(url, "meetings", fmt.Sprintf("year=%d", year)) +} + +func (s *OpenF1Source) FetchMeetingsForMeetingKey(meetingKey int) (FetchResult, []models.Meeting, error) { + url := fmt.Sprintf("%s/v1/meetings?meeting_key=%d", s.client.BaseURL(), meetingKey) + return s.fetchMeetings(url, "meetings", fmt.Sprintf("meeting_key=%d", meetingKey)) +} + +func (s *OpenF1Source) FetchSessionsForMeeting(meetingKey int) (FetchResult, []models.Session, error) { + url := fmt.Sprintf("%s/v1/sessions?meeting_key=%d", s.client.BaseURL(), meetingKey) + return s.fetchSessions(url, "sessions", fmt.Sprintf("meeting_key=%d", meetingKey)) +} + +func (s *OpenF1Source) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []models.Session, error) { + url := fmt.Sprintf("%s/v1/sessions?session_key=%d", s.client.BaseURL(), sessionKey) + return s.fetchSessions(url, "sessions", fmt.Sprintf("session_key=%d", sessionKey)) +} + +func (s *OpenF1Source) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) { + url := fmt.Sprintf("%s/v1/drivers?session_key=%d", s.client.BaseURL(), sessionKey) + return s.fetchDrivers(url, "drivers", fmt.Sprintf("session_key=%d", sessionKey)) +} + +func (s *OpenF1Source) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) { + url := fmt.Sprintf("%s/v1/session_result?session_key=%d", s.client.BaseURL(), sessionKey) + return s.fetchSessionResults(url, "session_result", fmt.Sprintf("session_key=%d", sessionKey)) +} + +func (s *OpenF1Source) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) { + url := fmt.Sprintf("%s/v1/starting_grid?session_key=%d", s.client.BaseURL(), sessionKey) + return s.fetchStartingGrid(url, "starting_grid", fmt.Sprintf("session_key=%d", sessionKey)) +} + +func (s *OpenF1Source) fetchMeetings(url, endpoint, requestKey string) (FetchResult, []models.Meeting, error) { + body, err := s.client.FetchStrict(url) + if err != nil { + return FetchResult{}, nil, err + } + var result []models.Meeting + if err := json.Unmarshal(body, &result); err != nil { + return FetchResult{}, nil, err + } + return FetchResult{ + Endpoint: endpoint, + RequestKey: requestKey, + URL: url, + Body: body, + FetchedAt: time.Now(), + }, result, nil +} + +func (s *OpenF1Source) fetchSessions(url, endpoint, requestKey string) (FetchResult, []models.Session, error) { + body, err := s.client.FetchStrict(url) + if err != nil { + return FetchResult{}, nil, err + } + var result []models.Session + if err := json.Unmarshal(body, &result); err != nil { + return FetchResult{}, nil, err + } + return FetchResult{ + Endpoint: endpoint, + RequestKey: requestKey, + URL: url, + Body: body, + FetchedAt: time.Now(), + }, result, nil +} + +func (s *OpenF1Source) fetchDrivers(url, endpoint, requestKey string) (FetchResult, []models.Driver, error) { + body, err := s.client.FetchStrict(url) + if err != nil { + return FetchResult{}, nil, err + } + var result []models.Driver + if err := json.Unmarshal(body, &result); err != nil { + return FetchResult{}, nil, err + } + return FetchResult{ + Endpoint: endpoint, + RequestKey: requestKey, + URL: url, + Body: body, + FetchedAt: time.Now(), + }, result, nil +} + +func (s *OpenF1Source) fetchSessionResults(url, endpoint, requestKey string) (FetchResult, []models.SessionResult, error) { + body, err := s.client.FetchStrict(url) + if err != nil { + return FetchResult{}, nil, err + } + var result []models.SessionResult + if err := json.Unmarshal(body, &result); err != nil { + return FetchResult{}, nil, err + } + return FetchResult{ + Endpoint: endpoint, + RequestKey: requestKey, + URL: url, + Body: body, + FetchedAt: time.Now(), + }, result, nil +} + +func (s *OpenF1Source) fetchStartingGrid(url, endpoint, requestKey string) (FetchResult, []models.StartingGrid, error) { + body, err := s.client.FetchStrict(url) + if err != nil { + return FetchResult{}, nil, err + } + var result []models.StartingGrid + if err := json.Unmarshal(body, &result); err != nil { + return FetchResult{}, nil, err + } + return FetchResult{ + Endpoint: endpoint, + RequestKey: requestKey, + URL: url, + Body: body, + FetchedAt: time.Now(), + }, result, nil +} + +func meetingToStore(m models.Meeting) store.Meeting { + return store.Meeting{ + MeetingKey: int(m.MeetingKey), + MeetingName: m.MeetingName, + MeetingOfficialName: m.MeetingOfficialName, + Location: m.Location, + CountryCode: m.CountryCode, + CountryName: m.CountryName, + CircuitKey: m.CircuitKey, + CircuitShortName: m.CircuitShortName, + GMTOffset: m.GMTOffset, + DateStart: m.DateStart, + DateEnd: m.DateEnd, + Year: m.Year, + } +} + +func sessionToStore(s models.Session) store.Session { + return store.Session{ + SessionKey: s.SessionKey, + MeetingKey: s.MeetingKey, + SessionName: s.SessionName, + SessionType: s.SessionType, + CircuitKey: s.CircuitKey, + DateStart: s.DateStart, + DateEnd: s.DateEnd, + GMTOffset: s.GMTOffset, + } +} + +func driverToStore(d models.Driver) store.Driver { + return store.Driver{ + DriverNumber: d.DriverNumber, + BroadcastName: d.BroadcastName, + FirstName: d.FirstName, + FullName: d.FullName, + LastName: d.LastName, + NameAcronym: d.NameAcronym, + HeadshotURL: d.HeadshotURL, + TeamName: d.TeamName, + TeamColour: d.TeamColour, + } +} + +func sessionDriverToStore(d models.Driver) store.SessionDriver { + return store.SessionDriver{ + SessionKey: d.SessionKey, + DriverNumber: d.DriverNumber, + MeetingKey: d.MeetingKey, + TeamName: d.TeamName, + TeamColour: d.TeamColour, + } +} + +func sessionResultToStore(r models.SessionResult) store.SessionResult { + return store.SessionResult{ + SessionKey: r.SessionKey, + DriverNumber: r.DriverNumber, + MeetingKey: r.MeetingKey, + Position: r.Position, + Points: r.Points, + NumberOfLaps: r.NumberOfLaps, + DurationJSON: jsonField(r.Duration), + GapToLeaderJSON: jsonField(r.GapToLeader), + DNF: r.DNF, + DNS: r.DNS, + DSQ: r.DSQ, + } +} + +func startingGridToStore(g models.StartingGrid) store.StartingGridEntry { + return store.StartingGridEntry{ + SessionKey: g.SessionKey, + DriverNumber: g.DriverNumber, + MeetingKey: g.MeetingKey, + Position: g.Position, + LapDuration: g.LapDuration, + } +} + +func jsonField(v any) string { + if v == nil { + return "" + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} + +func provenanceJSON(fetch FetchResult) string { + meta := map[string]string{ + "url": fetch.URL, + } + b, err := json.Marshal(meta) + if err != nil { + return "" + } + return string(b) +} diff --git a/internal/ingest/progress.go b/internal/ingest/progress.go new file mode 100644 index 0000000..c359ee8 --- /dev/null +++ b/internal/ingest/progress.go @@ -0,0 +1,49 @@ +package ingest + +import ( + "fmt" + "io" + "os" +) + +// Progress reports ingestion progress to a writer. +type Progress struct { + w io.Writer +} + +// NewProgress returns a progress helper writing to w, or stderr when w is nil. +func NewProgress(w io.Writer) *Progress { + if w == nil { + w = os.Stderr + } + return &Progress{w: w} +} + +func (p *Progress) Step(format string, args ...any) { + fmt.Fprintf(p.w, "ingest: "+format+"\n", args...) +} + +func (p *Progress) Summary(summary Summary) { + fmt.Fprintf(p.w, "\ningest summary (%s %s): status=%s\n", summary.ScopeType, summary.ScopeKey, summary.Status) + if summary.Meetings > 0 { + fmt.Fprintf(p.w, " meetings: %d\n", summary.Meetings) + } + if summary.Sessions > 0 { + fmt.Fprintf(p.w, " sessions: %d\n", summary.Sessions) + } + if summary.Drivers > 0 { + fmt.Fprintf(p.w, " drivers: %d\n", summary.Drivers) + } + if summary.SessionResults > 0 { + fmt.Fprintf(p.w, " session results: %d\n", summary.SessionResults) + } + if summary.StartingGrid > 0 { + fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid) + } + if summary.RawPayloads > 0 { + fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted) + } + for _, errMsg := range summary.Errors { + fmt.Fprintf(p.w, " error: %s\n", errMsg) + } +} diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go new file mode 100644 index 0000000..637a019 --- /dev/null +++ b/internal/live/parser_test.go @@ -0,0 +1,323 @@ +package live_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/live" +) + +func TestProcessMessageFullState(t *testing.T) { + state := live.NewState() + msg := []byte(`{ + "R": { + "TimingData": {"Lines": {"1": {"Position": "1", "RacingNumber": "1", "LastLapTime": {"Value": "1:32.456"}}}}, + "DriverList": {"1": {"RacingNumber": "1", "Tla": "VER", "TeamName": "Red Bull"}}, + "LapCount": {"CurrentLap": 12, "TotalLaps": 57}, + "TrackStatus": {"Status": "1", "Message": "AllClear"}, + "WeatherData": {"AirTemp": "24.5", "TrackTemp": "38.0", "Humidity": "55", "WindSpeed": "2.1", "WindDirection": "180", "Rainfall": "0"}, + "SessionInfo": {"Meeting": {"Name": "Monaco Grand Prix"}, "Name": "Race", "Type": "Race"}, + "ExtrapolatedClock": {"Remaining": "0:45:00", "Utc": "2025-05-25T14:00:00Z", "Extrapolating": true} + } + }`) + + if !state.ProcessMessage(msg) { + t.Fatal("expected full-state message to produce updates") + } + + snap := state.Snapshot() + if snap.Drivers["1"].Position != 1 { + t.Errorf("driver position = %d, want 1", snap.Drivers["1"].Position) + } + if snap.Drivers["1"].LastLapTime != "1:32.456" { + t.Errorf("last lap = %q, want 1:32.456", snap.Drivers["1"].LastLapTime) + } + if snap.DriverInfo["1"].Tla != "VER" { + t.Errorf("TLA = %q, want VER", snap.DriverInfo["1"].Tla) + } + if snap.CurrentLap != 12 || snap.TotalLaps != 57 { + t.Errorf("laps = %d/%d, want 12/57", snap.CurrentLap, snap.TotalLaps) + } + if snap.TrackStatus != "1" { + t.Errorf("track status = %q, want 1", snap.TrackStatus) + } + if snap.Weather.AirTemp != 24.5 || snap.Weather.TrackTemp != 38.0 { + t.Errorf("weather temps = %.1f/%.1f, want 24.5/38.0", snap.Weather.AirTemp, snap.Weather.TrackTemp) + } + if snap.Session.MeetingName != "Monaco Grand Prix" || snap.Session.SessionName != "Race" { + t.Errorf("session = %+v", snap.Session) + } + if snap.Clock != "0:45:00" || !snap.ClockExtrapolating { + t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating) + } +} + +func TestProcessMessageIncremental(t *testing.T) { + state := live.NewState() + msg := []byte(`{ + "M": [{ + "A": ["TimingData", {"Lines": {"44": {"Position": "2", "GapToLeader": "+1.234", "IntervalToPositionAhead": {"Value": "+0.456"}}}}] + }] + }`) + + if !state.ProcessMessage(msg) { + t.Fatal("expected incremental message to produce updates") + } + + d := state.Snapshot().Drivers["44"] + if d.Position != 2 { + t.Errorf("position = %d, want 2", d.Position) + } + if d.GapToLeader != "+1.234" { + t.Errorf("gap = %q, want +1.234", d.GapToLeader) + } + if d.Interval != "+0.456" { + t.Errorf("interval = %q, want +0.456", d.Interval) + } +} + +func TestProcessTopicTimingData(t *testing.T) { + state := live.NewState() + data := json.RawMessage(`{ + "Lines": { + "16": { + "Position": 3, + "GapToLeader": 2.5, + "NumberOfLaps": "15", + "Sectors": { + "0": {"Value": "28.123", "PersonalFastest": true}, + "1": {"Value": "31.456"}, + "2": {"Value": ""} + }, + "Speeds": {"ST": {"Value": "312"}} + } + } + }`) + + if !state.ProcessTopic("TimingData", data) { + t.Fatal("TimingData should update state") + } + + d := state.Snapshot().Drivers["16"] + if d.Position != 3 { + t.Errorf("position = %d, want 3", d.Position) + } + if d.GapToLeader != "+2.500" { + t.Errorf("gap = %q, want +2.500", d.GapToLeader) + } + if d.NumberOfLaps != 15 { + t.Errorf("laps = %d, want 15", d.NumberOfLaps) + } + if d.Sectors[0].Value != "28.123" || !d.Sectors[0].PersonalFastest { + t.Errorf("sector 0 = %+v", d.Sectors[0]) + } + if d.Sectors[2].Value != "" { + t.Errorf("sector 2 should be cleared, got %q", d.Sectors[2].Value) + } + if !d.OnFlyingLap { + t.Error("expected OnFlyingLap=true when S1/S2 set and S3 empty") + } + if d.SpeedTrap != "312" { + t.Errorf("speed trap = %q, want 312", d.SpeedTrap) + } +} + +func TestProcessTopicDriverList(t *testing.T) { + state := live.NewState() + data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`) + + state.ProcessTopic("DriverList", data) + if len(state.Snapshot().DriverInfo) != 1 { + t.Fatalf("expected 1 driver info entry") + } + + // Entries without TLA are ignored. + data2 := json.RawMessage(`{"99": {"RacingNumber": "99", "TeamName": "Unknown"}}`) + state.ProcessTopic("DriverList", data2) + if _, ok := state.Snapshot().DriverInfo["99"]; ok { + t.Error("driver without TLA should be ignored") + } +} + +func TestProcessTopicLapCount(t *testing.T) { + state := live.NewState() + data := json.RawMessage(`{"CurrentLap": "5", "TotalLaps": "78"}`) + state.ProcessTopic("LapCount", data) + snap := state.Snapshot() + if snap.CurrentLap != 5 || snap.TotalLaps != 78 { + t.Errorf("laps = %d/%d, want 5/78", snap.CurrentLap, snap.TotalLaps) + } +} + +func TestProcessTopicExtrapolatedClock(t *testing.T) { + state := live.NewState() + data := json.RawMessage(`{"Remaining": "1:00:00", "Utc": "2025-05-25T15:04:05.123Z", "Extrapolating": true}`) + state.ProcessTopic("ExtrapolatedClock", data) + snap := state.Snapshot() + if snap.Clock != "1:00:00" || !snap.ClockExtrapolating { + t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating) + } + want := time.Date(2025, 5, 25, 15, 4, 5, 123000000, time.UTC) + if !snap.ClockRefTime.Equal(want) { + t.Errorf("ClockRefTime = %v, want %v", snap.ClockRefTime, want) + } +} + +func TestProcessTopicTrackStatus(t *testing.T) { + state := live.NewState() + state.ProcessTopic("TrackStatus", json.RawMessage(`{"Status": "4", "Message": "SC DEPLOYED"}`)) + if state.Snapshot().TrackStatus != "4" { + t.Errorf("track status = %q, want 4", state.Snapshot().TrackStatus) + } +} + +func TestProcessTopicRaceControlMessages(t *testing.T) { + state := live.NewState() + data := json.RawMessage(`{ + "Messages": { + "1": {"Utc": "2025-05-25T15:04:30Z", "Category": "Flag", "Flag": "YELLOW", "Message": "Yellow in sector 2", "Lap": 8} + } + }`) + state.ProcessTopic("RaceControlMessages", data) + rc := state.Snapshot().RCMessages + if len(rc) != 1 { + t.Fatalf("expected 1 RC message, got %d", len(rc)) + } + if rc[0].Time != "15:04" || rc[0].Flag != "YELLOW" || rc[0].Message != "Yellow in sector 2" || rc[0].Lap != 8 { + t.Errorf("RC message = %+v", rc[0]) + } +} + +func TestProcessTopicWeatherData(t *testing.T) { + state := live.NewState() + state.ProcessTopic("WeatherData", json.RawMessage(`{ + "AirTemp": 22, "TrackTemp": 35, "Humidity": 60, "WindSpeed": 3.5, "WindDirection": 90, "Rainfall": 1 + }`)) + w := state.Snapshot().Weather + if w.AirTemp != 22 || w.TrackTemp != 35 || w.Humidity != 60 || w.WindSpeed != 3.5 || w.WindDir != 90 || !w.Rainfall { + t.Errorf("weather = %+v", w) + } +} + +func TestProcessTopicSessionInfo(t *testing.T) { + state := live.NewState() + state.ProcessTopic("SessionInfo", json.RawMessage(`{ + "Meeting": {"Name": "British Grand Prix"}, + "Name": "Qualifying", + "Type": "Qualifying" + }`)) + s := state.Snapshot().Session + if s.MeetingName != "British Grand Prix" || s.SessionName != "Qualifying" || s.SessionType != "Qualifying" { + t.Errorf("session = %+v", s) + } +} + +func TestProcessTopicCurrentTyres(t *testing.T) { + state := live.NewState() + state.Tyres["1"] = live.LiveTyreData{Age: 7} + state.ProcessTopic("CurrentTyres", json.RawMessage(`{ + "1": {"Compound": "SOFT", "New": "true"}, + "_kf": {"Compound": "ignore"} + }`)) + tyre := state.Snapshot().Tyres["1"] + if tyre.Compound != "SOFT" || !tyre.New { + t.Errorf("tyre = %+v", tyre) + } + if tyre.Age != 7 { + t.Errorf("age should be preserved from prior state, got %d", tyre.Age) + } +} + +func TestProcessTopicTimingAppData(t *testing.T) { + state := live.NewState() + data := json.RawMessage(`{ + "Lines": { + "4": { + "Stints": { + "0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}, + "1": {"Compound": "HARD", "New": "false", "TotalLaps": 18} + } + } + } + }`) + state.ProcessTopic("TimingAppData", data) + snap := state.Snapshot() + stints := snap.Stints["4"] + if len(stints) != 2 { + t.Fatalf("expected 2 stints, got %d", len(stints)) + } + tyre := snap.Tyres["4"] + if tyre.Compound != "HARD" || tyre.Age != 18 || tyre.New { + t.Errorf("tyre synced from stint = %+v", tyre) + } +} + +func TestProcessTopicTimingStats(t *testing.T) { + state := live.NewState() + state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"} + state.ProcessTopic("TimingStats", json.RawMessage(`{ + "Lines": {"55": {"PersonalBestLapTime": {"Value": "1:28.999"}}} + }`)) + if state.Snapshot().Drivers["55"].BestLapTime != "1:28.999" { + t.Errorf("best lap = %q", state.Snapshot().Drivers["55"].BestLapTime) + } +} + +func TestProcessTopicUnknownIgnored(t *testing.T) { + state := live.NewState() + if state.ProcessTopic("Heartbeat", json.RawMessage(`{"Seq": 1}`)) { + t.Error("unknown topic should not mark state updated") + } + if state.ProcessTopic("TotallyUnknown", json.RawMessage(`{"foo": "bar"}`)) { + t.Error("unknown topic should not mark state updated") + } +} + +func TestSnapshotCopiesMapsAndSlices(t *testing.T) { + state := live.NewState() + state.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 1, GapToLeader: "+0.000"} + state.DriverInfo["1"] = live.F1DriverListEntry{Tla: "VER"} + state.Tyres["1"] = live.LiveTyreData{Compound: "SOFT", Age: 5} + state.Stints["1"] = []live.LiveStintData{{Compound: "SOFT", Laps: 5}} + state.RCMessages = []live.LiveRCMessage{{Message: "Green flag"}} + + snap := state.Snapshot() + + snap.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 99} + snap.DriverInfo["1"] = live.F1DriverListEntry{Tla: "MUTATED"} + snap.Tyres["1"] = live.LiveTyreData{Compound: "WET"} + snap.Stints["1"][0].Compound = "WET" + snap.RCMessages[0].Message = "mutated" + + inner := state.Snapshot() + if inner.Drivers["1"].Position != 1 { + t.Error("mutating snapshot drivers leaked into state") + } + if inner.DriverInfo["1"].Tla != "VER" { + t.Error("mutating snapshot driverInfo leaked into state") + } + if inner.Tyres["1"].Compound != "SOFT" { + t.Error("mutating snapshot tyres leaked into state") + } + if inner.Stints["1"][0].Compound != "SOFT" { + t.Error("mutating snapshot stints leaked into state") + } + if inner.RCMessages[0].Message != "Green flag" { + t.Error("mutating snapshot RC messages leaked into state") + } +} + +func TestProcessMessageInvalidJSON(t *testing.T) { + state := live.NewState() + if state.ProcessMessage([]byte(`not json`)) { + t.Error("invalid JSON should not update state") + } +} + +func TestProcessMessageEmptyPayload(t *testing.T) { + state := live.NewState() + if state.ProcessMessage([]byte(`{}`)) { + t.Error("empty envelope should not update state") + } +} diff --git a/internal/live/signalr.go b/internal/live/signalr.go new file mode 100644 index 0000000..346f8a2 --- /dev/null +++ b/internal/live/signalr.go @@ -0,0 +1,82 @@ +package live + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/gorilla/websocket" +) + +// ConnectToF1LiveTiming negotiates with the official F1 SignalR hub, subscribes +// to timing topics, and sends defensive snapshots on dataChan until the +// connection closes. +func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { + hubName := `[{"name":"Streaming"}]` + negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName)) + + req, err := http.NewRequest("GET", negotiateURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + cookies := resp.Cookies() + defer resp.Body.Close() + + var neg struct { + ConnectionToken string `json:"ConnectionToken"` + } + if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil { + return err + } + + wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s", + url.QueryEscape(neg.ConnectionToken), + url.QueryEscape(hubName), + ) + + header := http.Header{} + for _, cookie := range cookies { + header.Add("Cookie", cookie.String()) + } + header.Add("User-Agent", "BestHTTP") + + c, _, err := websocket.DefaultDialer.Dial(wsURL, header) + if err != nil { + return err + } + + subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) + err = c.WriteMessage(websocket.TextMessage, subscribeMsg) + if err != nil { + return err + } + + go func() { + defer c.Close() + state := NewState() + + for { + _, message, err := c.ReadMessage() + if err != nil { + log.Println("WS Read Error:", err) + return + } + + if state.ProcessMessage(message) { + select { + case dataChan <- state.Snapshot(): + default: + } + } + } + }() + + return nil +} diff --git a/internal/live/state.go b/internal/live/state.go new file mode 100644 index 0000000..8bdfb4a --- /dev/null +++ b/internal/live/state.go @@ -0,0 +1,495 @@ +package live + +import ( + "encoding/json" + "fmt" + "time" +) + +// State accumulates live timing updates from SignalR topic payloads. +type State struct { + Drivers map[string]LiveDriverData + DriverInfo map[string]F1DriverListEntry + Tyres map[string]LiveTyreData + Stints map[string][]LiveStintData + RCMessages []LiveRCMessage + Weather LiveWeatherData + Session LiveSessionMeta + TrackStatus string + CurrentLap int + TotalLaps int + Clock string + ClockRefTime time.Time + ClockExtrapolating bool +} + +// NewState returns an empty live timing accumulator. +func NewState() *State { + return &State{ + Drivers: make(map[string]LiveDriverData), + DriverInfo: make(map[string]F1DriverListEntry), + Tyres: make(map[string]LiveTyreData), + Stints: make(map[string][]LiveStintData), + } +} + +// Snapshot returns a defensive copy of the current state. +func (s *State) Snapshot() LiveStreamData { + cpyDrivers := make(map[string]LiveDriverData, len(s.Drivers)) + for k, v := range s.Drivers { + cpyDrivers[k] = v + } + cpyInfo := make(map[string]F1DriverListEntry, len(s.DriverInfo)) + for k, v := range s.DriverInfo { + cpyInfo[k] = v + } + cpyTyres := make(map[string]LiveTyreData, len(s.Tyres)) + for k, v := range s.Tyres { + cpyTyres[k] = v + } + cpyRC := make([]LiveRCMessage, len(s.RCMessages)) + copy(cpyRC, s.RCMessages) + cpyStints := make(map[string][]LiveStintData, len(s.Stints)) + for k, v := range s.Stints { + st := make([]LiveStintData, len(v)) + copy(st, v) + cpyStints[k] = st + } + + return LiveStreamData{ + Drivers: cpyDrivers, + DriverInfo: cpyInfo, + Tyres: cpyTyres, + RCMessages: cpyRC, + Weather: s.Weather, + Session: s.Session, + TrackStatus: s.TrackStatus, + CurrentLap: s.CurrentLap, + TotalLaps: s.TotalLaps, + Clock: s.Clock, + ClockRefTime: s.ClockRefTime, + ClockExtrapolating: s.ClockExtrapolating, + Stints: cpyStints, + } +} + +// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates. +func (s *State) ProcessMessage(message []byte) bool { + var parsed F1SignalRMessage + if err := json.Unmarshal(message, &parsed); err != nil { + return false + } + + updated := false + + if len(parsed.R) > 2 { + var rMap map[string]json.RawMessage + if err := json.Unmarshal(parsed.R, &rMap); err == nil { + for topic, data := range rMap { + if s.ProcessTopic(topic, data) { + updated = true + } + } + } + } + + for _, m := range parsed.M { + if len(m.A) > 1 { + var topic string + json.Unmarshal(m.A[0], &topic) + if s.ProcessTopic(topic, m.A[1]) { + updated = true + } + } + } + + return updated +} + +// ProcessTopic applies a single topic payload to the accumulator. +func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { + updated := false + switch topic { + case "TimingData": + var td struct { + Lines map[string]json.RawMessage `json:"Lines"` + } + if json.Unmarshal(data, &td) == nil { + for num, lineRaw := range td.Lines { + var line F1TimingLine + if json.Unmarshal(lineRaw, &line) == nil { + updateDriver(s.Drivers, num, line) + updated = true + } + } + } + case "DriverList": + var dlMap map[string]json.RawMessage + if json.Unmarshal(data, &dlMap) == nil { + for num, entryRaw := range dlMap { + var entry F1DriverListEntry + if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" { + s.DriverInfo[num] = entry + updated = true + } + } + } + case "LapCount": + var lc struct { + CurrentLap json.Number `json:"CurrentLap"` + TotalLaps json.Number `json:"TotalLaps"` + } + if json.Unmarshal(data, &lc) == nil { + if v, err := lc.CurrentLap.Int64(); err == nil { + s.CurrentLap = int(v) + } + if v, err := lc.TotalLaps.Int64(); err == nil { + s.TotalLaps = int(v) + } + updated = true + } + case "ExtrapolatedClock": + var ec struct { + Remaining string `json:"Remaining"` + Utc string `json:"Utc"` + Extrapolating bool `json:"Extrapolating"` + } + if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" { + s.Clock = ec.Remaining + s.ClockExtrapolating = ec.Extrapolating + if ec.Utc != "" { + if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil { + s.ClockRefTime = t + } else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil { + s.ClockRefTime = t + } else { + s.ClockRefTime = time.Now() + } + } else { + s.ClockRefTime = time.Now() + } + updated = true + } + case "TrackStatus": + var ts struct { + Status string `json:"Status"` + Message string `json:"Message"` + } + if json.Unmarshal(data, &ts) == nil && ts.Status != "" { + s.TrackStatus = ts.Status + updated = true + } + case "RaceControlMessages": + var rcm struct { + Messages map[string]json.RawMessage `json:"Messages"` + } + if json.Unmarshal(data, &rcm) == nil { + for _, msgRaw := range rcm.Messages { + var msg struct { + Utc string `json:"Utc"` + Category string `json:"Category"` + Flag string `json:"Flag"` + Message string `json:"Message"` + Lap int `json:"Lap"` + } + if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" { + t := "" + if len(msg.Utc) >= 19 { + t = msg.Utc[11:16] + } + s.RCMessages = append(s.RCMessages, LiveRCMessage{ + Time: t, + Category: msg.Category, + Flag: msg.Flag, + Message: msg.Message, + Lap: msg.Lap, + }) + updated = true + } + } + } + case "WeatherData": + var wd struct { + AirTemp json.Number `json:"AirTemp"` + TrackTemp json.Number `json:"TrackTemp"` + Humidity json.Number `json:"Humidity"` + WindSpeed json.Number `json:"WindSpeed"` + WindDirection json.Number `json:"WindDirection"` + Rainfall json.Number `json:"Rainfall"` + } + if json.Unmarshal(data, &wd) == nil { + if v, err := wd.AirTemp.Float64(); err == nil { + s.Weather.AirTemp = v + } + if v, err := wd.TrackTemp.Float64(); err == nil { + s.Weather.TrackTemp = v + } + if v, err := wd.Humidity.Float64(); err == nil { + s.Weather.Humidity = v + } + if v, err := wd.WindSpeed.Float64(); err == nil { + s.Weather.WindSpeed = v + } + if v, err := wd.WindDirection.Int64(); err == nil { + s.Weather.WindDir = int(v) + } + if v, err := wd.Rainfall.Float64(); err == nil { + s.Weather.Rainfall = v > 0 + } + updated = true + } + case "SessionInfo": + var si struct { + Meeting struct { + Name string `json:"Name"` + } `json:"Meeting"` + Name string `json:"Name"` + Type string `json:"Type"` + } + if json.Unmarshal(data, &si) == nil { + if si.Meeting.Name != "" { + s.Session.MeetingName = si.Meeting.Name + } + if si.Name != "" { + s.Session.SessionName = si.Name + } + if si.Type != "" { + s.Session.SessionType = si.Type + } + updated = true + } + case "CurrentTyres": + var ct map[string]json.RawMessage + if json.Unmarshal(data, &ct) == nil { + for num, raw := range ct { + if num == "_kf" { + continue + } + var td struct { + Compound string `json:"Compound"` + New string `json:"New"` + } + if json.Unmarshal(raw, &td) == nil && td.Compound != "" { + t := s.Tyres[num] + t.Compound = td.Compound + t.New = td.New == "true" || td.New == "True" + s.Tyres[num] = t + updated = true + } + } + } + case "TimingAppData": + var tad struct { + Lines map[string]json.RawMessage `json:"Lines"` + } + if json.Unmarshal(data, &tad) == nil { + for num, lineRaw := range tad.Lines { + var line struct { + Stints map[string]json.RawMessage `json:"Stints"` + } + if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { + var driverStints []LiveStintData + for _, sRaw := range line.Stints { + var st struct { + Compound string `json:"Compound"` + New string `json:"New"` + TotalLaps int `json:"TotalLaps"` + } + if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" { + driverStints = append(driverStints, LiveStintData{ + Compound: st.Compound, + New: st.New == "true" || st.New == "True", + Laps: st.TotalLaps, + }) + } + } + if len(driverStints) > 0 { + s.Stints[num] = driverStints + lastStint := driverStints[len(driverStints)-1] + t := s.Tyres[num] + t.Age = lastStint.Laps + if lastStint.Compound != "" { + t.Compound = lastStint.Compound + t.New = lastStint.New + } + s.Tyres[num] = t + updated = true + } + } + } + } + case "TimingStats": + var ts struct { + Lines map[string]json.RawMessage `json:"Lines"` + } + if json.Unmarshal(data, &ts) == nil { + for num, lineRaw := range ts.Lines { + var line struct { + PersonalBestLapTime struct { + Value string `json:"Value"` + } `json:"PersonalBestLapTime"` + } + if json.Unmarshal(lineRaw, &line) == nil { + if d, ok := s.Drivers[num]; ok && line.PersonalBestLapTime.Value != "" { + d.BestLapTime = line.PersonalBestLapTime.Value + s.Drivers[num] = d + updated = true + } + } + } + } + } + return updated +} + +func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) { + d, exists := drivers[num] + if !exists { + d = LiveDriverData{RacingNumber: num} + if line.RacingNumber != "" { + d.RacingNumber = line.RacingNumber + } + } + + if line.Position != nil { + var newPos int + switch v := line.Position.(type) { + case string: + fmt.Sscanf(v, "%d", &newPos) + case float64: + newPos = int(v) + } + if newPos > 0 && newPos != d.Position { + d.PrevPosition = d.Position + d.Position = newPos + } + } + if line.GapToLeader != nil { + if s := extractStringVal(line.GapToLeader); s != "" { + d.GapToLeader = s + } + } + if line.IntervalToPositionAhead.Value != nil { + if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" { + d.Interval = s + } + } + if line.LastLapTime.Value != "" { + d.LastLapTime = line.LastLapTime.Value + d.LastLapPB = line.LastLapTime.PersonalFastest + d.LastLapOB = line.LastLapTime.OverallFastest + } + if line.BestLapTime.Value != "" { + d.BestLapTime = line.BestLapTime.Value + d.BestLapPB = line.BestLapTime.PersonalFastest + d.BestLapOB = line.BestLapTime.OverallFastest + if line.BestLapTime.Lap > 0 { + d.BestLapNum = line.BestLapTime.Lap + } + } + if line.InPit != nil { + d.InPit = toBool(line.InPit) + } + if line.PitOut != nil { + d.PitOut = toBool(line.PitOut) + } + if line.Retired != nil { + d.Retired = toBool(line.Retired) + } + if line.KnockedOut != nil { + d.KnockedOut = toBool(line.KnockedOut) + } + if line.Cutoff != nil { + d.Cutoff = toBool(line.Cutoff) + } + if line.NumberOfLaps != nil { + if v, ok := toInt(line.NumberOfLaps); ok { + d.NumberOfLaps = v + } + } + + if st, ok := line.Speeds["ST"]; ok { + var sp struct { + Value string `json:"Value"` + } + if json.Unmarshal(st, &sp) == nil && sp.Value != "" { + d.SpeedTrap = sp.Value + } + } + + for idx, sRaw := range line.Sectors { + i := 0 + fmt.Sscanf(idx, "%d", &i) + if i >= 0 && i < 3 { + var sec struct { + Value string `json:"Value"` + PersonalFastest bool `json:"PersonalFastest"` + OverallFastest bool `json:"OverallFastest"` + } + if json.Unmarshal(sRaw, &sec) == nil { + if sec.Value == "" { + d.Sectors[i] = LiveSectorData{} + } else { + d.Sectors[i] = LiveSectorData{ + Value: sec.Value, + PersonalFastest: sec.PersonalFastest, + OverallFastest: sec.OverallFastest, + } + } + } + } + } + + d.OnFlyingLap = !d.InPit && !d.Retired && + (d.Sectors[0].Value != "" || d.Sectors[1].Value != "") && + d.Sectors[2].Value == "" + + drivers[num] = d +} + +func extractStringVal(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + if val == 0 { + return "" + } + return fmt.Sprintf("+%.3f", val) + case map[string]interface{}: + if s, ok := val["Value"].(string); ok { + return s + } + } + return "" +} + +func toBool(v interface{}) bool { + switch val := v.(type) { + case bool: + return val + case string: + return val == "true" || val == "True" + } + return false +} + +func toInt(v interface{}) (int, bool) { + switch val := v.(type) { + case float64: + return int(val), true + case json.Number: + if i, err := val.Int64(); err == nil { + return int(i), true + } + case string: + var i int + if _, err := fmt.Sscanf(val, "%d", &i); err == nil { + return i, true + } + } + return 0, false +} diff --git a/internal/live/types.go b/internal/live/types.go new file mode 100644 index 0000000..f9fbef6 --- /dev/null +++ b/internal/live/types.go @@ -0,0 +1,144 @@ +package live + +import ( + "encoding/json" + "time" +) + +// F1SignalRMessage is the top-level envelope from the official F1 SignalR feed. +type F1SignalRMessage struct { + M []struct { + A []json.RawMessage `json:"A"` + } `json:"M"` + R json.RawMessage `json:"R"` +} + +// F1TimingLine is a single driver's timing row from TimingData. +type F1TimingLine struct { + GapToLeader interface{} `json:"GapToLeader"` + IntervalToPositionAhead struct { + Value interface{} `json:"Value"` + } `json:"IntervalToPositionAhead"` + Position interface{} `json:"Position"` + RacingNumber string `json:"RacingNumber"` + LastLapTime struct { + Value string `json:"Value"` + PersonalFastest bool `json:"PersonalFastest"` + OverallFastest bool `json:"OverallFastest"` + } `json:"LastLapTime"` + BestLapTime struct { + Value string `json:"Value"` + PersonalFastest bool `json:"PersonalFastest"` + OverallFastest bool `json:"OverallFastest"` + Lap int `json:"Lap"` + } `json:"BestLapTime"` + InPit interface{} `json:"InPit"` + PitOut interface{} `json:"PitOut"` + Retired interface{} `json:"Retired"` + KnockedOut interface{} `json:"KnockedOut"` + Cutoff interface{} `json:"Cutoff"` + NumberOfLaps interface{} `json:"NumberOfLaps"` + Sectors map[string]json.RawMessage `json:"Sectors"` + Speeds map[string]json.RawMessage `json:"Speeds"` +} + +// F1DriverListEntry is driver metadata from the DriverList topic. +type F1DriverListEntry struct { + RacingNumber string `json:"RacingNumber"` + BroadcastName string `json:"BroadcastName"` + Tla string `json:"Tla"` + TeamName string `json:"TeamName"` + TeamColour string `json:"TeamColour"` + FirstName string `json:"FirstName"` + LastName string `json:"LastName"` +} + +// LiveTyreData holds current tyre compound and age for a driver. +type LiveTyreData struct { + Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET + New bool + Age int // laps on current set +} + +// LiveRCMessage is a parsed race control message. +type LiveRCMessage struct { + Time string // "15:04" formatted + Category string // Flag, SafetyCar, Drs, Other + Flag string // GREEN, YELLOW, RED, etc. + Message string + Lap int +} + +// LiveWeatherData holds session weather readings. +type LiveWeatherData struct { + AirTemp float64 + TrackTemp float64 + Humidity float64 + WindSpeed float64 + WindDir int + Rainfall bool +} + +// LiveSessionMeta holds session and meeting metadata. +type LiveSessionMeta struct { + MeetingName string + CircuitName string + SessionType string + SessionName string +} + +// LiveSectorData holds a single sector time and flags. +type LiveSectorData struct { + Value string + PersonalFastest bool + OverallFastest bool +} + +// LiveDriverData is the normalized timing state for one driver. +type LiveDriverData struct { + RacingNumber string + Position int + PrevPosition int + GapToLeader string + Interval string + LastLapTime string + LastLapPB bool // personal best + LastLapOB bool // overall best + BestLapTime string + BestLapPB bool // just set a new personal best + BestLapOB bool // overall fastest in session + BestLapNum int // lap number when best was set + InPit bool + PitOut bool + Retired bool + KnockedOut bool // eliminated in qualifying + Cutoff bool // currently in elimination zone (danger zone) + OnFlyingLap bool // currently running a timed lap (derived from sector state) + NumberOfLaps int + SpeedTrap string // fastest recorded speed at speed trap + Sectors [3]LiveSectorData +} + +// LiveStintData is one stint in a driver's tyre history. +type LiveStintData struct { + Compound string + New bool + Laps int +} + +// LiveStreamData is an immutable snapshot of all live timing state. +type LiveStreamData struct { + Drivers map[string]LiveDriverData + DriverInfo map[string]F1DriverListEntry + Tyres map[string]LiveTyreData + RCMessages []LiveRCMessage + Weather LiveWeatherData + Session LiveSessionMeta + TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC + CurrentLap int + TotalLaps int + Clock string // "HH:MM:SS" remaining at ClockRefTime + ClockRefTime time.Time // UTC when Clock was accurate + ClockExtrapolating bool // true = actively counting down + Stints map[string][]LiveStintData +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..4618bd6 --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,92 @@ +package store + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// Store owns the local domain SQLite database. +type Store struct { + db *sql.DB +} + +// Open opens or creates a domain database at path and applies pending migrations. +func Open(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + + dsn := path + "?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=ON" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(1) + + s := &Store{db: db} + if err := s.applyMigrations(); err != nil { + _ = db.Close() + return nil, err + } + + return s, nil +} + +// OpenDefault opens the default user domain database path. +func OpenDefault() (*Store, error) { + return Open(DefaultDBPath()) +} + +// DefaultDBPath returns the default domain database file path. +func DefaultDBPath() string { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, ".local", "share", "box-box", "boxbox.db") + } + return filepath.Join(".local", "share", "box-box", "boxbox.db") +} + +// DB exposes the underlying connection for advanced callers and tests. +func (s *Store) DB() *sql.DB { + return s.db +} + +// Close closes the database connection. +func (s *Store) Close() error { + if s.db == nil { + return nil + } + return s.db.Close() +} + +// WithTx runs fn inside a transaction, rolling back on error. +func (s *Store) WithTx(fn func(tx *sql.Tx) error) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +// SchemaVersion returns the highest applied migration version. +func (s *Store) SchemaVersion() (int, error) { + var version sql.NullInt64 + err := s.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version) + if err != nil { + return 0, err + } + if !version.Valid { + return 0, nil + } + return int(version.Int64), nil +} diff --git a/internal/store/meetings.go b/internal/store/meetings.go new file mode 100644 index 0000000..f0d97a7 --- /dev/null +++ b/internal/store/meetings.go @@ -0,0 +1,295 @@ +package store + +import ( + "database/sql" + "fmt" + "time" +) + +// UpsertMeeting inserts or updates a meeting by meeting_key. +func (s *Store) UpsertMeeting(m Meeting) error { + if m.UpdatedAt.IsZero() { + m.UpdatedAt = time.Now() + } + + _, err := s.db.Exec(` + INSERT INTO meetings ( + meeting_key, meeting_name, meeting_official_name, location, + country_code, country_name, circuit_key, circuit_short_name, + gmt_offset, date_start, date_end, year, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(meeting_key) DO UPDATE SET + meeting_name = excluded.meeting_name, + meeting_official_name = excluded.meeting_official_name, + location = excluded.location, + country_code = excluded.country_code, + country_name = excluded.country_name, + circuit_key = excluded.circuit_key, + circuit_short_name = excluded.circuit_short_name, + gmt_offset = excluded.gmt_offset, + date_start = excluded.date_start, + date_end = excluded.date_end, + year = excluded.year, + updated_at = excluded.updated_at + `, + m.MeetingKey, + m.MeetingName, + nullString(m.MeetingOfficialName), + nullString(m.Location), + nullString(m.CountryCode), + nullString(m.CountryName), + nullableZeroInt(m.CircuitKey), + nullString(m.CircuitShortName), + nullString(m.GMTOffset), + nullString(m.DateStart), + nullString(m.DateEnd), + m.Year, + m.UpdatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert meeting: %w", err) + } + return nil +} + +// GetMeeting returns a meeting by key. +func (s *Store) GetMeeting(meetingKey int) (Meeting, error) { + var m Meeting + var updatedAt int64 + var officialName, location, countryCode, countryName sql.NullString + var circuitKey sql.NullInt64 + var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString + + err := s.db.QueryRow(` + SELECT meeting_key, meeting_name, meeting_official_name, location, + country_code, country_name, circuit_key, circuit_short_name, + gmt_offset, date_start, date_end, year, updated_at + FROM meetings + WHERE meeting_key = ? + `, meetingKey).Scan( + &m.MeetingKey, + &m.MeetingName, + &officialName, + &location, + &countryCode, + &countryName, + &circuitKey, + &circuitShortName, + &gmtOffset, + &dateStart, + &dateEnd, + &m.Year, + &updatedAt, + ) + if err != nil { + return Meeting{}, err + } + + m.MeetingOfficialName = officialName.String + m.Location = location.String + m.CountryCode = countryCode.String + m.CountryName = countryName.String + if circuitKey.Valid { + m.CircuitKey = int(circuitKey.Int64) + } + m.CircuitShortName = circuitShortName.String + m.GMTOffset = gmtOffset.String + m.DateStart = dateStart.String + m.DateEnd = dateEnd.String + m.UpdatedAt = time.Unix(updatedAt, 0) + return m, nil +} + +// ListMeetingsByYear returns meetings for a season ordered by start date. +func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) { + rows, err := s.db.Query(` + SELECT meeting_key, meeting_name, meeting_official_name, location, + country_code, country_name, circuit_key, circuit_short_name, + gmt_offset, date_start, date_end, year, updated_at + FROM meetings + WHERE year = ? + ORDER BY date_start ASC, meeting_key ASC + `, year) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanMeetings(rows) +} + +// UpsertSession inserts or updates a session by session_key. +func (s *Store) UpsertSession(sess Session) error { + if sess.UpdatedAt.IsZero() { + sess.UpdatedAt = time.Now() + } + + _, err := s.db.Exec(` + INSERT INTO sessions ( + session_key, meeting_key, session_name, session_type, + circuit_key, date_start, date_end, gmt_offset, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_key) DO UPDATE SET + meeting_key = excluded.meeting_key, + session_name = excluded.session_name, + session_type = excluded.session_type, + circuit_key = excluded.circuit_key, + date_start = excluded.date_start, + date_end = excluded.date_end, + gmt_offset = excluded.gmt_offset, + updated_at = excluded.updated_at + `, + sess.SessionKey, + sess.MeetingKey, + sess.SessionName, + sess.SessionType, + nullableZeroInt(sess.CircuitKey), + nullString(sess.DateStart), + nullString(sess.DateEnd), + nullString(sess.GMTOffset), + sess.UpdatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert session: %w", err) + } + return nil +} + +// GetSession returns a session by key. +func (s *Store) GetSession(sessionKey int) (Session, error) { + var sess Session + var updatedAt int64 + var circuitKey sql.NullInt64 + var dateStart, dateEnd, gmtOffset sql.NullString + + err := s.db.QueryRow(` + SELECT session_key, meeting_key, session_name, session_type, + circuit_key, date_start, date_end, gmt_offset, updated_at + FROM sessions + WHERE session_key = ? + `, sessionKey).Scan( + &sess.SessionKey, + &sess.MeetingKey, + &sess.SessionName, + &sess.SessionType, + &circuitKey, + &dateStart, + &dateEnd, + &gmtOffset, + &updatedAt, + ) + if err != nil { + return Session{}, err + } + + if circuitKey.Valid { + sess.CircuitKey = int(circuitKey.Int64) + } + sess.DateStart = dateStart.String + sess.DateEnd = dateEnd.String + sess.GMTOffset = gmtOffset.String + sess.UpdatedAt = time.Unix(updatedAt, 0) + return sess, nil +} + +// ListSessionsByMeeting returns sessions for a meeting ordered by start time. +func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) { + rows, err := s.db.Query(` + SELECT session_key, meeting_key, session_name, session_type, + circuit_key, date_start, date_end, gmt_offset, updated_at + FROM sessions + WHERE meeting_key = ? + ORDER BY date_start ASC, session_key ASC + `, meetingKey) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanSessions(rows) +} + +func scanMeetings(rows *sql.Rows) ([]Meeting, error) { + var out []Meeting + for rows.Next() { + var m Meeting + var updatedAt int64 + var officialName, location, countryCode, countryName sql.NullString + var circuitKey sql.NullInt64 + var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString + + if err := rows.Scan( + &m.MeetingKey, + &m.MeetingName, + &officialName, + &location, + &countryCode, + &countryName, + &circuitKey, + &circuitShortName, + &gmtOffset, + &dateStart, + &dateEnd, + &m.Year, + &updatedAt, + ); err != nil { + return nil, err + } + + m.MeetingOfficialName = officialName.String + m.Location = location.String + m.CountryCode = countryCode.String + m.CountryName = countryName.String + if circuitKey.Valid { + m.CircuitKey = int(circuitKey.Int64) + } + m.CircuitShortName = circuitShortName.String + m.GMTOffset = gmtOffset.String + m.DateStart = dateStart.String + m.DateEnd = dateEnd.String + m.UpdatedAt = time.Unix(updatedAt, 0) + out = append(out, m) + } + return out, rows.Err() +} + +func scanSessions(rows *sql.Rows) ([]Session, error) { + var out []Session + for rows.Next() { + var sess Session + var updatedAt int64 + var circuitKey sql.NullInt64 + var dateStart, dateEnd, gmtOffset sql.NullString + + if err := rows.Scan( + &sess.SessionKey, + &sess.MeetingKey, + &sess.SessionName, + &sess.SessionType, + &circuitKey, + &dateStart, + &dateEnd, + &gmtOffset, + &updatedAt, + ); err != nil { + return nil, err + } + + if circuitKey.Valid { + sess.CircuitKey = int(circuitKey.Int64) + } + sess.DateStart = dateStart.String + sess.DateEnd = dateEnd.String + sess.GMTOffset = gmtOffset.String + sess.UpdatedAt = time.Unix(updatedAt, 0) + out = append(out, sess) + } + return out, rows.Err() +} + +func nullableZeroInt(v int) any { + if v == 0 { + return nil + } + return v +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..5d6dd8a --- /dev/null +++ b/internal/store/migrations.go @@ -0,0 +1,92 @@ +package store + +import ( + "embed" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +func (s *Store) applyMigrations() error { + if _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + `); err != nil { + return fmt.Errorf("bootstrap schema_migrations: %w", err) + } + + entries, err := migrationFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + + var files []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + files = append(files, entry.Name()) + } + sort.Strings(files) + + for _, name := range files { + version, err := migrationVersion(name) + if err != nil { + return err + } + + applied, err := s.isMigrationApplied(version) + if err != nil { + return err + } + if applied { + continue + } + + sqlBytes, err := migrationFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + if _, err := s.db.Exec(string(sqlBytes)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + + if _, err := s.db.Exec( + `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, + version, time.Now().Unix(), + ); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + + return nil +} + +func migrationVersion(name string) (int, error) { + prefix := strings.SplitN(name, "_", 2)[0] + version, err := strconv.Atoi(prefix) + if err != nil { + return 0, fmt.Errorf("invalid migration filename %q: %w", name, err) + } + return version, nil +} + +func (s *Store) isMigrationApplied(version int) (bool, error) { + var count int + err := s.db.QueryRow( + `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, + version, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/store/migrations/001_initial.sql b/internal/store/migrations/001_initial.sql new file mode 100644 index 0000000..f00daad --- /dev/null +++ b/internal/store/migrations/001_initial.sql @@ -0,0 +1,118 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS raw_payloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + endpoint TEXT NOT NULL, + request_key TEXT NOT NULL, + meeting_key INTEGER, + session_key INTEGER, + payload TEXT NOT NULL, + payload_hash TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + provenance_json TEXT, + UNIQUE (source, request_key, payload_hash) +); + +CREATE INDEX IF NOT EXISTS idx_raw_payloads_meeting ON raw_payloads (meeting_key); +CREATE INDEX IF NOT EXISTS idx_raw_payloads_session ON raw_payloads (session_key); + +CREATE TABLE IF NOT EXISTS ingestion_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL, + scope_key TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + status TEXT NOT NULL, + refresh INTEGER NOT NULL DEFAULT 0, + summary_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_ingestion_runs_scope ON ingestion_runs (scope_type, scope_key); + +CREATE TABLE IF NOT EXISTS meetings ( + meeting_key INTEGER PRIMARY KEY, + meeting_name TEXT NOT NULL, + meeting_official_name TEXT, + location TEXT, + country_code TEXT, + country_name TEXT, + circuit_key INTEGER, + circuit_short_name TEXT, + gmt_offset TEXT, + date_start TEXT, + date_end TEXT, + year INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_meetings_year ON meetings (year); + +CREATE TABLE IF NOT EXISTS sessions ( + session_key INTEGER PRIMARY KEY, + meeting_key INTEGER NOT NULL REFERENCES meetings (meeting_key), + session_name TEXT NOT NULL, + session_type TEXT NOT NULL, + circuit_key INTEGER, + date_start TEXT, + date_end TEXT, + gmt_offset TEXT, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_meeting ON sessions (meeting_key); + +CREATE TABLE IF NOT EXISTS drivers ( + driver_number INTEGER PRIMARY KEY, + broadcast_name TEXT, + first_name TEXT, + full_name TEXT NOT NULL, + last_name TEXT, + name_acronym TEXT, + headshot_url TEXT, + team_name TEXT, + team_colour TEXT, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS session_drivers ( + session_key INTEGER NOT NULL, + driver_number INTEGER NOT NULL, + meeting_key INTEGER NOT NULL, + team_name TEXT, + team_colour TEXT, + PRIMARY KEY (session_key, driver_number) +); + +CREATE INDEX IF NOT EXISTS idx_session_drivers_meeting ON session_drivers (meeting_key); + +CREATE TABLE IF NOT EXISTS session_results ( + session_key INTEGER NOT NULL, + driver_number INTEGER NOT NULL, + meeting_key INTEGER NOT NULL, + position INTEGER NOT NULL, + points REAL NOT NULL DEFAULT 0, + number_of_laps INTEGER, + duration_json TEXT, + gap_to_leader_json TEXT, + dnf INTEGER NOT NULL DEFAULT 0, + dns INTEGER NOT NULL DEFAULT 0, + dsq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (session_key, driver_number) +); + +CREATE INDEX IF NOT EXISTS idx_session_results_meeting ON session_results (meeting_key); + +CREATE TABLE IF NOT EXISTS starting_grid ( + session_key INTEGER NOT NULL, + driver_number INTEGER NOT NULL, + meeting_key INTEGER NOT NULL, + position INTEGER NOT NULL, + lap_duration REAL, + PRIMARY KEY (session_key, driver_number) +); + +CREATE INDEX IF NOT EXISTS idx_starting_grid_meeting ON starting_grid (meeting_key); diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..65a4710 --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,106 @@ +package store + +import "time" + +// RawPayload stores a fetched source payload with provenance metadata. +type RawPayload struct { + ID int64 + Source string + Endpoint string + RequestKey string + MeetingKey *int + SessionKey *int + Payload string + PayloadHash string + FetchedAt time.Time + ProvenanceJSON string +} + +// IngestionRun tracks a scoped ingestion attempt. +type IngestionRun struct { + ID int64 + ScopeType string + ScopeKey string + StartedAt time.Time + FinishedAt *time.Time + Status string + Refresh bool + SummaryJSON string +} + +// Meeting is a race weekend record. +type Meeting struct { + MeetingKey int + MeetingName string + MeetingOfficialName string + Location string + CountryCode string + CountryName string + CircuitKey int + CircuitShortName string + GMTOffset string + DateStart string + DateEnd string + Year int + UpdatedAt time.Time +} + +// Session is a session within a meeting. +type Session struct { + SessionKey int + MeetingKey int + SessionName string + SessionType string + CircuitKey int + DateStart string + DateEnd string + GMTOffset string + UpdatedAt time.Time +} + +// Driver is a driver identity record. +type Driver struct { + DriverNumber int + BroadcastName string + FirstName string + FullName string + LastName string + NameAcronym string + HeadshotURL string + TeamName string + TeamColour string + UpdatedAt time.Time +} + +// SessionDriver links a driver to a session with session-specific team info. +type SessionDriver struct { + SessionKey int + DriverNumber int + MeetingKey int + TeamName string + TeamColour string +} + +// SessionResult is a final classification row for a session. +type SessionResult struct { + SessionKey int + DriverNumber int + MeetingKey int + Position int + Points float64 + NumberOfLaps int + DurationJSON string + GapToLeaderJSON string + DNF bool + DNS bool + DSQ bool +} + +// StartingGridEntry is a starting grid position for a session. +type StartingGridEntry struct { + SessionKey int + DriverNumber int + MeetingKey int + Position int + LapDuration float64 +} diff --git a/internal/store/raw.go b/internal/store/raw.go new file mode 100644 index 0000000..0d98293 --- /dev/null +++ b/internal/store/raw.go @@ -0,0 +1,177 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "time" +) + +// InsertRawPayload stores a raw payload if the source/request/hash tuple is new. +// Returns the row ID and true when inserted, or the existing ID and false on duplicate. +func (s *Store) InsertRawPayload(p RawPayload) (int64, bool, error) { + if p.PayloadHash == "" { + p.PayloadHash = hashPayload(p.Payload) + } + if p.FetchedAt.IsZero() { + p.FetchedAt = time.Now() + } + + result, err := s.db.Exec(` + INSERT INTO raw_payloads ( + source, endpoint, request_key, meeting_key, session_key, + payload, payload_hash, fetched_at, provenance_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source, request_key, payload_hash) DO NOTHING + `, + p.Source, + p.Endpoint, + p.RequestKey, + nullableInt(p.MeetingKey), + nullableInt(p.SessionKey), + p.Payload, + p.PayloadHash, + p.FetchedAt.Unix(), + nullString(p.ProvenanceJSON), + ) + if err != nil { + return 0, false, fmt.Errorf("insert raw payload: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, false, err + } + if rows == 0 { + id, err := s.findRawPayloadID(p.Source, p.RequestKey, p.PayloadHash) + return id, false, err + } + + id, err := result.LastInsertId() + return id, true, err +} + +// GetRawPayload returns a raw payload by ID. +func (s *Store) GetRawPayload(id int64) (RawPayload, error) { + var p RawPayload + var fetchedAt int64 + var meetingKey, sessionKey sql.NullInt64 + var provenance sql.NullString + + err := s.db.QueryRow(` + SELECT id, source, endpoint, request_key, meeting_key, session_key, + payload, payload_hash, fetched_at, provenance_json + FROM raw_payloads + WHERE id = ? + `, id).Scan( + &p.ID, + &p.Source, + &p.Endpoint, + &p.RequestKey, + &meetingKey, + &sessionKey, + &p.Payload, + &p.PayloadHash, + &fetchedAt, + &provenance, + ) + if err != nil { + return RawPayload{}, err + } + + p.FetchedAt = time.Unix(fetchedAt, 0) + p.MeetingKey = nullIntPtr(meetingKey) + p.SessionKey = nullIntPtr(sessionKey) + if provenance.Valid { + p.ProvenanceJSON = provenance.String + } + return p, nil +} + +// ListRawPayloadsBySession returns raw payloads for a session ordered by fetch time. +func (s *Store) ListRawPayloadsBySession(sessionKey int) ([]RawPayload, error) { + rows, err := s.db.Query(` + SELECT id, source, endpoint, request_key, meeting_key, session_key, + payload, payload_hash, fetched_at, provenance_json + FROM raw_payloads + WHERE session_key = ? + ORDER BY fetched_at ASC, id ASC + `, sessionKey) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanRawPayloads(rows) +} + +func (s *Store) findRawPayloadID(source, requestKey, payloadHash string) (int64, error) { + var id int64 + err := s.db.QueryRow(` + SELECT id FROM raw_payloads + WHERE source = ? AND request_key = ? AND payload_hash = ? + `, source, requestKey, payloadHash).Scan(&id) + return id, err +} + +func scanRawPayloads(rows *sql.Rows) ([]RawPayload, error) { + var out []RawPayload + for rows.Next() { + var p RawPayload + var fetchedAt int64 + var meetingKey, sessionKey sql.NullInt64 + var provenance sql.NullString + + if err := rows.Scan( + &p.ID, + &p.Source, + &p.Endpoint, + &p.RequestKey, + &meetingKey, + &sessionKey, + &p.Payload, + &p.PayloadHash, + &fetchedAt, + &provenance, + ); err != nil { + return nil, err + } + + p.FetchedAt = time.Unix(fetchedAt, 0) + p.MeetingKey = nullIntPtr(meetingKey) + p.SessionKey = nullIntPtr(sessionKey) + if provenance.Valid { + p.ProvenanceJSON = provenance.String + } + out = append(out, p) + } + return out, rows.Err() +} + +func hashPayload(payload string) string { + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} + +func nullableInt(v *int) any { + if v == nil { + return nil + } + return *v +} + +func nullIntPtr(v sql.NullInt64) *int { + if !v.Valid { + return nil + } + n := int(v.Int64) + return &n +} + +func nullString(v string) any { + if v == "" { + return nil + } + return v +} diff --git a/internal/store/results.go b/internal/store/results.go new file mode 100644 index 0000000..5761eb0 --- /dev/null +++ b/internal/store/results.go @@ -0,0 +1,302 @@ +package store + +import ( + "database/sql" + "fmt" + "time" +) + +// UpsertDriver inserts or updates a driver by driver_number. +func (s *Store) UpsertDriver(d Driver) error { + if d.UpdatedAt.IsZero() { + d.UpdatedAt = time.Now() + } + + _, err := s.db.Exec(` + INSERT INTO drivers ( + driver_number, broadcast_name, first_name, full_name, last_name, + name_acronym, headshot_url, team_name, team_colour, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(driver_number) DO UPDATE SET + broadcast_name = excluded.broadcast_name, + first_name = excluded.first_name, + full_name = excluded.full_name, + last_name = excluded.last_name, + name_acronym = excluded.name_acronym, + headshot_url = excluded.headshot_url, + team_name = excluded.team_name, + team_colour = excluded.team_colour, + updated_at = excluded.updated_at + `, + d.DriverNumber, + nullString(d.BroadcastName), + nullString(d.FirstName), + d.FullName, + nullString(d.LastName), + nullString(d.NameAcronym), + nullString(d.HeadshotURL), + nullString(d.TeamName), + nullString(d.TeamColour), + d.UpdatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert driver: %w", err) + } + return nil +} + +// GetDriver returns a driver by number. +func (s *Store) GetDriver(driverNumber int) (Driver, error) { + var d Driver + var updatedAt int64 + var broadcastName, firstName, lastName, nameAcronym sql.NullString + var headshotURL, teamName, teamColour sql.NullString + + err := s.db.QueryRow(` + SELECT driver_number, broadcast_name, first_name, full_name, last_name, + name_acronym, headshot_url, team_name, team_colour, updated_at + FROM drivers + WHERE driver_number = ? + `, driverNumber).Scan( + &d.DriverNumber, + &broadcastName, + &firstName, + &d.FullName, + &lastName, + &nameAcronym, + &headshotURL, + &teamName, + &teamColour, + &updatedAt, + ) + if err != nil { + return Driver{}, err + } + + d.BroadcastName = broadcastName.String + d.FirstName = firstName.String + d.LastName = lastName.String + d.NameAcronym = nameAcronym.String + d.HeadshotURL = headshotURL.String + d.TeamName = teamName.String + d.TeamColour = teamColour.String + d.UpdatedAt = time.Unix(updatedAt, 0) + return d, nil +} + +// UpsertSessionDriver links a driver to a session. +func (s *Store) UpsertSessionDriver(sd SessionDriver) error { + _, err := s.db.Exec(` + INSERT INTO session_drivers ( + session_key, driver_number, meeting_key, team_name, team_colour + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_key, driver_number) DO UPDATE SET + meeting_key = excluded.meeting_key, + team_name = excluded.team_name, + team_colour = excluded.team_colour + `, + sd.SessionKey, + sd.DriverNumber, + sd.MeetingKey, + nullString(sd.TeamName), + nullString(sd.TeamColour), + ) + if err != nil { + return fmt.Errorf("upsert session driver: %w", err) + } + return nil +} + +// ListSessionDrivers returns drivers entered for a session ordered by number. +func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) { + rows, err := s.db.Query(` + SELECT session_key, driver_number, meeting_key, team_name, team_colour + FROM session_drivers + WHERE session_key = ? + ORDER BY driver_number ASC + `, sessionKey) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []SessionDriver + for rows.Next() { + var sd SessionDriver + var teamName, teamColour sql.NullString + if err := rows.Scan( + &sd.SessionKey, + &sd.DriverNumber, + &sd.MeetingKey, + &teamName, + &teamColour, + ); err != nil { + return nil, err + } + sd.TeamName = teamName.String + sd.TeamColour = teamColour.String + out = append(out, sd) + } + return out, rows.Err() +} + +// UpsertSessionResult inserts or updates a session classification row. +func (s *Store) UpsertSessionResult(r SessionResult) error { + _, err := s.db.Exec(` + INSERT INTO session_results ( + session_key, driver_number, meeting_key, position, points, + number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_key, driver_number) DO UPDATE SET + meeting_key = excluded.meeting_key, + position = excluded.position, + points = excluded.points, + number_of_laps = excluded.number_of_laps, + duration_json = excluded.duration_json, + gap_to_leader_json = excluded.gap_to_leader_json, + dnf = excluded.dnf, + dns = excluded.dns, + dsq = excluded.dsq + `, + r.SessionKey, + r.DriverNumber, + r.MeetingKey, + r.Position, + r.Points, + nullableZeroInt(r.NumberOfLaps), + nullString(r.DurationJSON), + nullString(r.GapToLeaderJSON), + boolInt(r.DNF), + boolInt(r.DNS), + boolInt(r.DSQ), + ) + if err != nil { + return fmt.Errorf("upsert session result: %w", err) + } + return nil +} + +// ListSessionResults returns classification rows ordered by finishing position. +func (s *Store) ListSessionResults(sessionKey int) ([]SessionResult, error) { + rows, err := s.db.Query(` + SELECT session_key, driver_number, meeting_key, position, points, + number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq + FROM session_results + WHERE session_key = ? + ORDER BY position ASC, driver_number ASC + `, sessionKey) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanSessionResults(rows) +} + +// UpsertStartingGridEntry inserts or updates a starting grid row. +func (s *Store) UpsertStartingGridEntry(g StartingGridEntry) error { + _, err := s.db.Exec(` + INSERT INTO starting_grid ( + session_key, driver_number, meeting_key, position, lap_duration + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_key, driver_number) DO UPDATE SET + meeting_key = excluded.meeting_key, + position = excluded.position, + lap_duration = excluded.lap_duration + `, + g.SessionKey, + g.DriverNumber, + g.MeetingKey, + g.Position, + nullableZeroFloat(g.LapDuration), + ) + if err != nil { + return fmt.Errorf("upsert starting grid: %w", err) + } + return nil +} + +// ListStartingGrid returns grid rows ordered by position. +func (s *Store) ListStartingGrid(sessionKey int) ([]StartingGridEntry, error) { + rows, err := s.db.Query(` + SELECT session_key, driver_number, meeting_key, position, lap_duration + FROM starting_grid + WHERE session_key = ? + ORDER BY position ASC, driver_number ASC + `, sessionKey) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []StartingGridEntry + for rows.Next() { + var g StartingGridEntry + var lapDuration sql.NullFloat64 + if err := rows.Scan( + &g.SessionKey, + &g.DriverNumber, + &g.MeetingKey, + &g.Position, + &lapDuration, + ); err != nil { + return nil, err + } + if lapDuration.Valid { + g.LapDuration = lapDuration.Float64 + } + out = append(out, g) + } + return out, rows.Err() +} + +func scanSessionResults(rows *sql.Rows) ([]SessionResult, error) { + var out []SessionResult + for rows.Next() { + var r SessionResult + var numberOfLaps sql.NullInt64 + var durationJSON, gapJSON sql.NullString + var dnf, dns, dsq int + + if err := rows.Scan( + &r.SessionKey, + &r.DriverNumber, + &r.MeetingKey, + &r.Position, + &r.Points, + &numberOfLaps, + &durationJSON, + &gapJSON, + &dnf, + &dns, + &dsq, + ); err != nil { + return nil, err + } + + if numberOfLaps.Valid { + r.NumberOfLaps = int(numberOfLaps.Int64) + } + r.DurationJSON = durationJSON.String + r.GapToLeaderJSON = gapJSON.String + r.DNF = dnf != 0 + r.DNS = dns != 0 + r.DSQ = dsq != 0 + out = append(out, r) + } + return out, rows.Err() +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func nullableZeroFloat(v float64) any { + if v == 0 { + return nil + } + return v +} diff --git a/internal/store/runs.go b/internal/store/runs.go new file mode 100644 index 0000000..d4fb306 --- /dev/null +++ b/internal/store/runs.go @@ -0,0 +1,31 @@ +package store + +import ( + "fmt" + "time" +) + +// CreateIngestionRun records the start of an ingestion attempt. +func (s *Store) CreateIngestionRun(scopeType, scopeKey string, refresh bool) (int64, error) { + result, err := s.db.Exec(` + INSERT INTO ingestion_runs (scope_type, scope_key, started_at, status, refresh) + VALUES (?, ?, ?, 'running', ?) + `, scopeType, scopeKey, time.Now().Unix(), boolInt(refresh)) + if err != nil { + return 0, fmt.Errorf("create ingestion run: %w", err) + } + return result.LastInsertId() +} + +// FinishIngestionRun marks an ingestion run complete with status and summary JSON. +func (s *Store) FinishIngestionRun(id int64, status, summaryJSON string) error { + _, err := s.db.Exec(` + UPDATE ingestion_runs + SET finished_at = ?, status = ?, summary_json = ? + WHERE id = ? + `, time.Now().Unix(), status, nullString(summaryJSON), id) + if err != nil { + return fmt.Errorf("finish ingestion run: %w", err) + } + return nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..a6ae5eb --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,450 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" + "time" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "test.db") + + s, err := Open(path) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestOpenAppliesMigrations(t *testing.T) { + s := openTestStore(t) + + version, err := s.SchemaVersion() + if err != nil { + t.Fatalf("SchemaVersion() error = %v", err) + } + if version != 1 { + t.Fatalf("SchemaVersion() = %d, want 1", version) + } + + tables := []string{ + "schema_migrations", + "raw_payloads", + "ingestion_runs", + "meetings", + "sessions", + "drivers", + "session_drivers", + "session_results", + "starting_grid", + } + for _, table := range tables { + var name string + err := s.db.QueryRow( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, + table, + ).Scan(&name) + if err != nil { + t.Fatalf("table %q missing: %v", table, err) + } + } +} + +func TestMigrationsAreIdempotent(t *testing.T) { + s := openTestStore(t) + + if err := s.applyMigrations(); err != nil { + t.Fatalf("second applyMigrations() error = %v", err) + } + if err := s.applyMigrations(); err != nil { + t.Fatalf("third applyMigrations() error = %v", err) + } + + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 1`).Scan(&count); err != nil { + t.Fatalf("count schema_migrations: %v", err) + } + if count != 1 { + t.Fatalf("schema_migrations count = %d, want 1", count) + } +} + +func TestRawPayloadInsertAndRead(t *testing.T) { + s := openTestStore(t) + + meetingKey := 1229 + sessionKey := 9472 + fetchedAt := time.Unix(1710000000, 0).UTC() + + payload := RawPayload{ + Source: "openf1", + Endpoint: "session_result", + RequestKey: "session_key=9472", + MeetingKey: &meetingKey, + SessionKey: &sessionKey, + Payload: `[{"position":1,"driver_number":1}]`, + PayloadHash: "abc123", + FetchedAt: fetchedAt, + ProvenanceJSON: `{"status":"ok","http_status":200}`, + } + + id, inserted, err := s.InsertRawPayload(payload) + if err != nil { + t.Fatalf("InsertRawPayload() error = %v", err) + } + if !inserted { + t.Fatal("InsertRawPayload() inserted = false, want true") + } + if id <= 0 { + t.Fatalf("InsertRawPayload() id = %d, want > 0", id) + } + + got, err := s.GetRawPayload(id) + if err != nil { + t.Fatalf("GetRawPayload() error = %v", err) + } + + if got.Source != payload.Source || + got.Endpoint != payload.Endpoint || + got.RequestKey != payload.RequestKey || + got.Payload != payload.Payload || + got.PayloadHash != payload.PayloadHash || + got.ProvenanceJSON != payload.ProvenanceJSON { + t.Fatalf("GetRawPayload() = %+v, want provenance preserved", got) + } + if got.MeetingKey == nil || *got.MeetingKey != meetingKey { + t.Fatalf("MeetingKey = %v, want %d", got.MeetingKey, meetingKey) + } + if got.SessionKey == nil || *got.SessionKey != sessionKey { + t.Fatalf("SessionKey = %v, want %d", got.SessionKey, sessionKey) + } + if !got.FetchedAt.Equal(fetchedAt) { + t.Fatalf("FetchedAt = %v, want %v", got.FetchedAt, fetchedAt) + } + + rows, err := s.ListRawPayloadsBySession(sessionKey) + if err != nil { + t.Fatalf("ListRawPayloadsBySession() error = %v", err) + } + if len(rows) != 1 || rows[0].ID != id { + t.Fatalf("ListRawPayloadsBySession() = %+v, want one row id=%d", rows, id) + } +} + +func TestRawPayloadDuplicateIsIdempotent(t *testing.T) { + s := openTestStore(t) + + payload := RawPayload{ + Source: "openf1", + Endpoint: "meetings", + RequestKey: "year=2025", + Payload: `[{"meeting_key":1229}]`, + PayloadHash: "dup-hash", + } + + firstID, inserted, err := s.InsertRawPayload(payload) + if err != nil { + t.Fatalf("first InsertRawPayload() error = %v", err) + } + if !inserted { + t.Fatal("first insert should succeed") + } + + secondID, inserted, err := s.InsertRawPayload(payload) + if err != nil { + t.Fatalf("second InsertRawPayload() error = %v", err) + } + if inserted { + t.Fatal("duplicate insert should not create a new row") + } + if secondID != firstID { + t.Fatalf("duplicate id = %d, want %d", secondID, firstID) + } + + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM raw_payloads`).Scan(&count); err != nil { + t.Fatalf("count raw_payloads: %v", err) + } + if count != 1 { + t.Fatalf("raw_payloads count = %d, want 1", count) + } +} + +func TestMeetingSessionDriverUpsertsAreIdempotent(t *testing.T) { + s := openTestStore(t) + + meeting := Meeting{ + MeetingKey: 1229, + MeetingName: "Monaco", + MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025", + Location: "Monaco", + CountryCode: "MON", + CountryName: "Monaco", + CircuitKey: 10, + CircuitShortName: "Monaco", + Year: 2025, + DateStart: "2025-05-23T00:00:00+00:00", + DateEnd: "2025-05-25T00:00:00+00:00", + } + updatedMeeting := meeting + updatedMeeting.MeetingName = "Monaco GP" + + for i := 0; i < 2; i++ { + m := meeting + if i == 1 { + m = updatedMeeting + } + if err := s.UpsertMeeting(m); err != nil { + t.Fatalf("UpsertMeeting(%d) error = %v", i, err) + } + } + + gotMeeting, err := s.GetMeeting(meeting.MeetingKey) + if err != nil { + t.Fatalf("GetMeeting() error = %v", err) + } + if gotMeeting.MeetingName != updatedMeeting.MeetingName { + t.Fatalf("MeetingName = %q, want %q", gotMeeting.MeetingName, updatedMeeting.MeetingName) + } + + var meetingCount int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM meetings`).Scan(&meetingCount); err != nil { + t.Fatalf("count meetings: %v", err) + } + if meetingCount != 1 { + t.Fatalf("meetings count = %d, want 1", meetingCount) + } + + session := Session{ + SessionKey: 9472, + MeetingKey: meeting.MeetingKey, + SessionName: "Race", + SessionType: "Race", + CircuitKey: 10, + DateStart: "2025-05-25T13:00:00+00:00", + } + updatedSession := session + updatedSession.DateEnd = "2025-05-25T15:00:00+00:00" + + for i := 0; i < 2; i++ { + sess := session + if i == 1 { + sess = updatedSession + } + if err := s.UpsertSession(sess); err != nil { + t.Fatalf("UpsertSession(%d) error = %v", i, err) + } + } + + gotSession, err := s.GetSession(session.SessionKey) + if err != nil { + t.Fatalf("GetSession() error = %v", err) + } + if gotSession.DateEnd != updatedSession.DateEnd { + t.Fatalf("DateEnd = %q, want %q", gotSession.DateEnd, updatedSession.DateEnd) + } + + driver := Driver{ + DriverNumber: 1, + FullName: "Max Verstappen", + NameAcronym: "VER", + TeamName: "Red Bull Racing", + TeamColour: "3671C6", + } + updatedDriver := driver + updatedDriver.TeamName = "Oracle Red Bull Racing" + + for i := 0; i < 2; i++ { + d := driver + if i == 1 { + d = updatedDriver + } + if err := s.UpsertDriver(d); err != nil { + t.Fatalf("UpsertDriver(%d) error = %v", i, err) + } + } + + gotDriver, err := s.GetDriver(driver.DriverNumber) + if err != nil { + t.Fatalf("GetDriver() error = %v", err) + } + if gotDriver.TeamName != updatedDriver.TeamName { + t.Fatalf("TeamName = %q, want %q", gotDriver.TeamName, updatedDriver.TeamName) + } + + sessionDriver := SessionDriver{ + SessionKey: session.SessionKey, + DriverNumber: driver.DriverNumber, + MeetingKey: meeting.MeetingKey, + TeamName: "Red Bull Racing", + TeamColour: "3671C6", + } + if err := s.UpsertSessionDriver(sessionDriver); err != nil { + t.Fatalf("UpsertSessionDriver() error = %v", err) + } + if err := s.UpsertSessionDriver(sessionDriver); err != nil { + t.Fatalf("second UpsertSessionDriver() error = %v", err) + } + + meetings, err := s.ListMeetingsByYear(2025) + if err != nil { + t.Fatalf("ListMeetingsByYear() error = %v", err) + } + if len(meetings) != 1 { + t.Fatalf("ListMeetingsByYear() len = %d, want 1", len(meetings)) + } + + sessions, err := s.ListSessionsByMeeting(meeting.MeetingKey) + if err != nil { + t.Fatalf("ListSessionsByMeeting() error = %v", err) + } + if len(sessions) != 1 { + t.Fatalf("ListSessionsByMeeting() len = %d, want 1", len(sessions)) + } + + sessionDrivers, err := s.ListSessionDrivers(session.SessionKey) + if err != nil { + t.Fatalf("ListSessionDrivers() error = %v", err) + } + if len(sessionDrivers) != 1 { + t.Fatalf("ListSessionDrivers() len = %d, want 1", len(sessionDrivers)) + } +} + +func TestSessionResultAndStartingGridUpsertRead(t *testing.T) { + s := openTestStore(t) + + meetingKey := 1229 + sessionKey := 9472 + + if err := s.UpsertMeeting(Meeting{ + MeetingKey: meetingKey, + MeetingName: "Monaco", + Year: 2025, + }); err != nil { + t.Fatalf("UpsertMeeting() error = %v", err) + } + if err := s.UpsertSession(Session{ + SessionKey: sessionKey, + MeetingKey: meetingKey, + SessionName: "Race", + SessionType: "Race", + }); err != nil { + t.Fatalf("UpsertSession() error = %v", err) + } + + result := SessionResult{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + Position: 1, + Points: 25, + NumberOfLaps: 78, + DurationJSON: "5234.567", + GapToLeaderJSON: "0", + } + updatedResult := result + updatedResult.Points = 26 + + for i := 0; i < 2; i++ { + r := result + if i == 1 { + r = updatedResult + } + if err := s.UpsertSessionResult(r); err != nil { + t.Fatalf("UpsertSessionResult(%d) error = %v", i, err) + } + } + + results, err := s.ListSessionResults(sessionKey) + if err != nil { + t.Fatalf("ListSessionResults() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("ListSessionResults() len = %d, want 1", len(results)) + } + if results[0].Points != updatedResult.Points { + t.Fatalf("Points = %v, want %v", results[0].Points, updatedResult.Points) + } + + grid := StartingGridEntry{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + Position: 1, + LapDuration: 71.234, + } + updatedGrid := grid + updatedGrid.LapDuration = 71.111 + + for i := 0; i < 2; i++ { + g := grid + if i == 1 { + g = updatedGrid + } + if err := s.UpsertStartingGridEntry(g); err != nil { + t.Fatalf("UpsertStartingGridEntry(%d) error = %v", i, err) + } + } + + grids, err := s.ListStartingGrid(sessionKey) + if err != nil { + t.Fatalf("ListStartingGrid() error = %v", err) + } + if len(grids) != 1 { + t.Fatalf("ListStartingGrid() len = %d, want 1", len(grids)) + } + if grids[0].LapDuration != updatedGrid.LapDuration { + t.Fatalf("LapDuration = %v, want %v", grids[0].LapDuration, updatedGrid.LapDuration) + } + + var resultCount int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM session_results`).Scan(&resultCount); err != nil { + t.Fatalf("count session_results: %v", err) + } + if resultCount != 1 { + t.Fatalf("session_results count = %d, want 1", resultCount) + } +} + +func TestWithTxRollback(t *testing.T) { + s := openTestStore(t) + + err := s.WithTx(func(tx *sql.Tx) error { + if _, err := tx.Exec(` + INSERT INTO meetings (meeting_key, meeting_name, year, updated_at) + VALUES (999, 'Rollback Test', 2025, ?) + `, time.Now().Unix()); err != nil { + return err + } + return assertAnError("rollback") + }) + if err == nil { + t.Fatal("WithTx() error = nil, want rollback error") + } + + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM meetings WHERE meeting_key = 999`).Scan(&count); err != nil { + t.Fatalf("count meetings: %v", err) + } + if count != 0 { + t.Fatalf("meetings count after rollback = %d, want 0", count) + } +} + +func assertAnError(msg string) error { + return &testError{msg: msg} +} + +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +} diff --git a/internal/ui/official_live.go b/internal/ui/official_live.go index a770014..c161fc4 100644 --- a/internal/ui/official_live.go +++ b/internal/ui/official_live.go @@ -1,707 +1,29 @@ package ui import ( - "encoding/json" "fmt" - "log" - "net/http" - "net/url" "sort" "strings" "time" + "github.com/AmanTahiliani/box-box/internal/live" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/gorilla/websocket" ) -// --------------------------------------------------------------------------- -// SignalR protocol types -// --------------------------------------------------------------------------- - -type F1SignalRMessage struct { - M []struct { - A []json.RawMessage `json:"A"` - } `json:"M"` - R json.RawMessage `json:"R"` -} - -// --------------------------------------------------------------------------- -// Data types from the WebSocket feed -// --------------------------------------------------------------------------- - -type F1TimingLine struct { - GapToLeader interface{} `json:"GapToLeader"` - IntervalToPositionAhead struct { - Value interface{} `json:"Value"` - } `json:"IntervalToPositionAhead"` - Position interface{} `json:"Position"` - RacingNumber string `json:"RacingNumber"` - LastLapTime struct { - Value string `json:"Value"` - PersonalFastest bool `json:"PersonalFastest"` - OverallFastest bool `json:"OverallFastest"` - } `json:"LastLapTime"` - BestLapTime struct { - Value string `json:"Value"` - PersonalFastest bool `json:"PersonalFastest"` - OverallFastest bool `json:"OverallFastest"` - Lap int `json:"Lap"` - } `json:"BestLapTime"` - InPit interface{} `json:"InPit"` - PitOut interface{} `json:"PitOut"` - Retired interface{} `json:"Retired"` - KnockedOut interface{} `json:"KnockedOut"` - Cutoff interface{} `json:"Cutoff"` - NumberOfLaps interface{} `json:"NumberOfLaps"` - Sectors map[string]json.RawMessage `json:"Sectors"` - Speeds map[string]json.RawMessage `json:"Speeds"` -} - -type F1DriverListEntry struct { - RacingNumber string `json:"RacingNumber"` - BroadcastName string `json:"BroadcastName"` - Tla string `json:"Tla"` - TeamName string `json:"TeamName"` - TeamColour string `json:"TeamColour"` - FirstName string `json:"FirstName"` - LastName string `json:"LastName"` -} - -type LiveTyreData struct { - Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET - New bool - Age int // laps on current set -} - -type LiveRCMessage struct { - Time string // "15:04" formatted - Category string // Flag, SafetyCar, Drs, Other - Flag string // GREEN, YELLOW, RED, etc. - Message string - Lap int -} - -type LiveWeatherData struct { - AirTemp float64 - TrackTemp float64 - Humidity float64 - WindSpeed float64 - WindDir int - Rainfall bool -} - -type LiveSessionMeta struct { - MeetingName string - CircuitName string - SessionType string - SessionName string -} - -type LiveSectorData struct { - Value string - PersonalFastest bool - OverallFastest bool -} - -type LiveDriverData struct { - RacingNumber string - Position int - PrevPosition int - GapToLeader string - Interval string - LastLapTime string - LastLapPB bool // personal best - LastLapOB bool // overall best - BestLapTime string - BestLapPB bool // just set a new personal best - BestLapOB bool // overall fastest in session - BestLapNum int // lap number when best was set - InPit bool - PitOut bool - Retired bool - KnockedOut bool // eliminated in qualifying - Cutoff bool // currently in elimination zone (danger zone) - OnFlyingLap bool // currently running a timed lap (derived from sector state) - NumberOfLaps int - SpeedTrap string // fastest recorded speed at speed trap - Sectors [3]LiveSectorData -} - -type LiveStintData struct { - Compound string - New bool - Laps int -} - -type LiveStreamData struct { - Drivers map[string]LiveDriverData - DriverInfo map[string]F1DriverListEntry - Tyres map[string]LiveTyreData - RCMessages []LiveRCMessage - Weather LiveWeatherData - Session LiveSessionMeta - TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC - CurrentLap int - TotalLaps int - Clock string // "HH:MM:SS" remaining at ClockRefTime - ClockRefTime time.Time // UTC when Clock was accurate - ClockExtrapolating bool // true = actively counting down - Stints map[string][]LiveStintData -} - -// --------------------------------------------------------------------------- -// WebSocket connection & parsing -// --------------------------------------------------------------------------- - -func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { - hubName := `[{"name":"Streaming"}]` - negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName)) - - req, err := http.NewRequest("GET", negotiateURL, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - - cookies := resp.Cookies() - defer resp.Body.Close() - - var neg struct { - ConnectionToken string `json:"ConnectionToken"` - } - if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil { - return err - } - - wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s", - url.QueryEscape(neg.ConnectionToken), - url.QueryEscape(hubName), - ) - - header := http.Header{} - for _, cookie := range cookies { - header.Add("Cookie", cookie.String()) - } - header.Add("User-Agent", "BestHTTP") - - c, _, err := websocket.DefaultDialer.Dial(wsURL, header) - if err != nil { - return err - } - - // Subscribe to all desired topics - subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) - err = c.WriteMessage(websocket.TextMessage, subscribeMsg) - if err != nil { - return err - } - - go func() { - defer c.Close() - drivers := make(map[string]LiveDriverData) - driverInfo := make(map[string]F1DriverListEntry) - tyres := make(map[string]LiveTyreData) - stints := make(map[string][]LiveStintData) - var rcMessages []LiveRCMessage - var weather LiveWeatherData - var session LiveSessionMeta - var trackStatus string - var currentLap, totalLaps int - var clock string - var clockRefTime time.Time - var clockExtrapolating bool - - sendUpdate := func() { - cpyDrivers := make(map[string]LiveDriverData) - for k, v := range drivers { - cpyDrivers[k] = v - } - cpyInfo := make(map[string]F1DriverListEntry) - for k, v := range driverInfo { - cpyInfo[k] = v - } - cpyTyres := make(map[string]LiveTyreData) - for k, v := range tyres { - cpyTyres[k] = v - } - cpyRC := make([]LiveRCMessage, len(rcMessages)) - copy(cpyRC, rcMessages) - cpyStints := make(map[string][]LiveStintData) - for k, v := range stints { - s := make([]LiveStintData, len(v)) - copy(s, v) - cpyStints[k] = s - } - - select { - case dataChan <- LiveStreamData{ - Drivers: cpyDrivers, - DriverInfo: cpyInfo, - Tyres: cpyTyres, - RCMessages: cpyRC, - Weather: weather, - Session: session, - TrackStatus: trackStatus, - CurrentLap: currentLap, - TotalLaps: totalLaps, - Clock: clock, - ClockRefTime: clockRefTime, - ClockExtrapolating: clockExtrapolating, - Stints: cpyStints, - }: - default: - } - } - - // processTopic handles a single topic's JSON payload (shared by R and M paths) - processTopic := func(topic string, data json.RawMessage) bool { - updated := false - switch topic { - case "TimingData": - var td struct { - Lines map[string]json.RawMessage `json:"Lines"` - } - if json.Unmarshal(data, &td) == nil { - for num, lineRaw := range td.Lines { - var line F1TimingLine - if json.Unmarshal(lineRaw, &line) == nil { - updateDriver(drivers, num, line) - updated = true - } - } - } - case "DriverList": - var dlMap map[string]json.RawMessage - if json.Unmarshal(data, &dlMap) == nil { - for num, entryRaw := range dlMap { - var entry F1DriverListEntry - if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" { - driverInfo[num] = entry - updated = true - } - } - } - case "LapCount": - var lc struct { - CurrentLap json.Number `json:"CurrentLap"` - TotalLaps json.Number `json:"TotalLaps"` - } - if json.Unmarshal(data, &lc) == nil { - if v, err := lc.CurrentLap.Int64(); err == nil { - currentLap = int(v) - } - if v, err := lc.TotalLaps.Int64(); err == nil { - totalLaps = int(v) - } - updated = true - } - case "ExtrapolatedClock": - var ec struct { - Remaining string `json:"Remaining"` - Utc string `json:"Utc"` - Extrapolating bool `json:"Extrapolating"` - } - if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" { - clock = ec.Remaining - clockExtrapolating = ec.Extrapolating - if ec.Utc != "" { - // Try RFC3339 first, then with milliseconds - if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil { - clockRefTime = t - } else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil { - clockRefTime = t - } else { - clockRefTime = time.Now() - } - } else { - clockRefTime = time.Now() - } - updated = true - } - case "TrackStatus": - var ts struct { - Status string `json:"Status"` - Message string `json:"Message"` - } - if json.Unmarshal(data, &ts) == nil && ts.Status != "" { - trackStatus = ts.Status - updated = true - } - case "RaceControlMessages": - var rcm struct { - Messages map[string]json.RawMessage `json:"Messages"` - } - if json.Unmarshal(data, &rcm) == nil { - for _, msgRaw := range rcm.Messages { - var msg struct { - Utc string `json:"Utc"` - Category string `json:"Category"` - Flag string `json:"Flag"` - Message string `json:"Message"` - Lap int `json:"Lap"` - } - if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" { - t := "" - if len(msg.Utc) >= 19 { - t = msg.Utc[11:16] - } - rcMessages = append(rcMessages, LiveRCMessage{ - Time: t, - Category: msg.Category, - Flag: msg.Flag, - Message: msg.Message, - Lap: msg.Lap, - }) - updated = true - } - } - } - case "WeatherData": - var wd struct { - AirTemp json.Number `json:"AirTemp"` - TrackTemp json.Number `json:"TrackTemp"` - Humidity json.Number `json:"Humidity"` - WindSpeed json.Number `json:"WindSpeed"` - WindDirection json.Number `json:"WindDirection"` - Rainfall json.Number `json:"Rainfall"` - } - if json.Unmarshal(data, &wd) == nil { - if v, err := wd.AirTemp.Float64(); err == nil { - weather.AirTemp = v - } - if v, err := wd.TrackTemp.Float64(); err == nil { - weather.TrackTemp = v - } - if v, err := wd.Humidity.Float64(); err == nil { - weather.Humidity = v - } - if v, err := wd.WindSpeed.Float64(); err == nil { - weather.WindSpeed = v - } - if v, err := wd.WindDirection.Int64(); err == nil { - weather.WindDir = int(v) - } - if v, err := wd.Rainfall.Float64(); err == nil { - weather.Rainfall = v > 0 - } - updated = true - } - case "SessionInfo": - var si struct { - Meeting struct { - Name string `json:"Name"` - } `json:"Meeting"` - Name string `json:"Name"` - Type string `json:"Type"` - } - if json.Unmarshal(data, &si) == nil { - if si.Meeting.Name != "" { - session.MeetingName = si.Meeting.Name - } - if si.Name != "" { - session.SessionName = si.Name - } - if si.Type != "" { - session.SessionType = si.Type - } - updated = true - } - case "CurrentTyres": - var ct map[string]json.RawMessage - if json.Unmarshal(data, &ct) == nil { - for num, raw := range ct { - if num == "_kf" { - continue - } - var td struct { - Compound string `json:"Compound"` - New string `json:"New"` - } - if json.Unmarshal(raw, &td) == nil && td.Compound != "" { - // Preserve the existing Age — CurrentTyres only carries - // compound and newness, not lap count. - t := tyres[num] - t.Compound = td.Compound - t.New = td.New == "true" || td.New == "True" - tyres[num] = t - updated = true - } - } - } - case "TimingAppData": - var tad struct { - Lines map[string]json.RawMessage `json:"Lines"` - } - if json.Unmarshal(data, &tad) == nil { - for num, lineRaw := range tad.Lines { - var line struct { - Stints map[string]json.RawMessage `json:"Stints"` - } - if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { - var driverStints []LiveStintData - for _, sRaw := range line.Stints { - var st struct { - Compound string `json:"Compound"` - New string `json:"New"` - TotalLaps int `json:"TotalLaps"` - } - if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" { - driverStints = append(driverStints, LiveStintData{ - Compound: st.Compound, - New: st.New == "true" || st.New == "True", - Laps: st.TotalLaps, - }) - } - } - if len(driverStints) > 0 { - stints[num] = driverStints - // Sync compound and age from the latest stint. - // Stints are authoritative: they include historical data and - // carry both compound and laps on the current set. - lastStint := driverStints[len(driverStints)-1] - t := tyres[num] - t.Age = lastStint.Laps - if lastStint.Compound != "" { - t.Compound = lastStint.Compound - t.New = lastStint.New - } - tyres[num] = t - updated = true - } - } - } - } - case "TimingStats": - var ts struct { - Lines map[string]json.RawMessage `json:"Lines"` - } - if json.Unmarshal(data, &ts) == nil { - for num, lineRaw := range ts.Lines { - var line struct { - PersonalBestLapTime struct { - Value string `json:"Value"` - } `json:"PersonalBestLapTime"` - } - if json.Unmarshal(lineRaw, &line) == nil { - if d, ok := drivers[num]; ok && line.PersonalBestLapTime.Value != "" { - d.BestLapTime = line.PersonalBestLapTime.Value - drivers[num] = d - updated = true - } - } - } - } - } - return updated - } - - for { - _, message, err := c.ReadMessage() - if err != nil { - log.Println("WS Read Error:", err) - return - } - - var parsed F1SignalRMessage - if err := json.Unmarshal(message, &parsed); err != nil { - continue - } - - updated := false - - // Full state payload (R) - if len(parsed.R) > 2 { - var rMap map[string]json.RawMessage - if err := json.Unmarshal(parsed.R, &rMap); err == nil { - for topic, data := range rMap { - if processTopic(topic, data) { - updated = true - } - } - } - } - - // Incremental feed (M) - for _, m := range parsed.M { - if len(m.A) > 1 { - var topic string - json.Unmarshal(m.A[0], &topic) - if processTopic(topic, m.A[1]) { - updated = true - } - } - } - - if updated { - sendUpdate() - } - } - }() - - return nil -} - -func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) { - d, exists := drivers[num] - if !exists { - d = LiveDriverData{RacingNumber: num} - if line.RacingNumber != "" { - d.RacingNumber = line.RacingNumber - } - } - - if line.Position != nil { - var newPos int - switch v := line.Position.(type) { - case string: - fmt.Sscanf(v, "%d", &newPos) - case float64: - newPos = int(v) - } - if newPos > 0 && newPos != d.Position { - d.PrevPosition = d.Position - d.Position = newPos - } - } - if line.GapToLeader != nil { - if s := extractStringVal(line.GapToLeader); s != "" { - d.GapToLeader = s - } - } - if line.IntervalToPositionAhead.Value != nil { - if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" { - d.Interval = s - } - } - if line.LastLapTime.Value != "" { - d.LastLapTime = line.LastLapTime.Value - d.LastLapPB = line.LastLapTime.PersonalFastest - d.LastLapOB = line.LastLapTime.OverallFastest - } - if line.BestLapTime.Value != "" { - d.BestLapTime = line.BestLapTime.Value - d.BestLapPB = line.BestLapTime.PersonalFastest - d.BestLapOB = line.BestLapTime.OverallFastest - if line.BestLapTime.Lap > 0 { - d.BestLapNum = line.BestLapTime.Lap - } - } - if line.InPit != nil { - d.InPit = toBool(line.InPit) - } - if line.PitOut != nil { - d.PitOut = toBool(line.PitOut) - } - if line.Retired != nil { - d.Retired = toBool(line.Retired) - } - if line.KnockedOut != nil { - d.KnockedOut = toBool(line.KnockedOut) - } - if line.Cutoff != nil { - d.Cutoff = toBool(line.Cutoff) - } - if line.NumberOfLaps != nil { - if v, ok := toInt(line.NumberOfLaps); ok { - d.NumberOfLaps = v - } - } - - // Parse speed trap (ST = highest speed on track) - if st, ok := line.Speeds["ST"]; ok { - var sp struct { - Value string `json:"Value"` - } - if json.Unmarshal(st, &sp) == nil && sp.Value != "" { - d.SpeedTrap = sp.Value - } - } - - // Parse sector times — handle empty Value as a sector clear (new lap starting) - for idx, sRaw := range line.Sectors { - i := 0 - fmt.Sscanf(idx, "%d", &i) - if i >= 0 && i < 3 { - var sec struct { - Value string `json:"Value"` - PersonalFastest bool `json:"PersonalFastest"` - OverallFastest bool `json:"OverallFastest"` - } - if json.Unmarshal(sRaw, &sec) == nil { - if sec.Value == "" { - d.Sectors[i] = LiveSectorData{} // clear = new lap starting - } else { - d.Sectors[i] = LiveSectorData{ - Value: sec.Value, - PersonalFastest: sec.PersonalFastest, - OverallFastest: sec.OverallFastest, - } - } - } - } - } - - // Derive: driver is on a flying lap if S1 or S2 populated but S3 not yet - d.OnFlyingLap = !d.InPit && !d.Retired && - (d.Sectors[0].Value != "" || d.Sectors[1].Value != "") && - d.Sectors[2].Value == "" - - drivers[num] = d -} - -// extractStringVal extracts a string from a timing value that may arrive as a -// plain string, a float64, or a {"Value": "..."} object from the SignalR feed. -func extractStringVal(v interface{}) string { - if v == nil { - return "" - } - switch val := v.(type) { - case string: - return val - case float64: - if val == 0 { - return "" - } - return fmt.Sprintf("+%.3f", val) - case map[string]interface{}: - if s, ok := val["Value"].(string); ok { - return s - } - } - return "" -} - -func toBool(v interface{}) bool { - switch val := v.(type) { - case bool: - return val - case string: - return val == "true" || val == "True" - } - return false -} - -func toInt(v interface{}) (int, bool) { - switch val := v.(type) { - case float64: - return int(val), true - case json.Number: - if i, err := val.Int64(); err == nil { - return int(i), true - } - case string: - var i int - if _, err := fmt.Sscanf(val, "%d", &i); err == nil { - return i, true - } - } - return 0, false -} +// Live timing types re-exported from internal/live for TUI sub-views. +type ( + F1DriverListEntry = live.F1DriverListEntry + LiveTyreData = live.LiveTyreData + LiveRCMessage = live.LiveRCMessage + LiveWeatherData = live.LiveWeatherData + LiveSessionMeta = live.LiveSessionMeta + LiveSectorData = live.LiveSectorData + LiveDriverData = live.LiveDriverData + LiveStintData = live.LiveStintData + LiveStreamData = live.LiveStreamData +) // --------------------------------------------------------------------------- // Model wrapper @@ -931,7 +253,7 @@ func NewOfficialLiveModel() OfficialLiveModel { } func (m OfficialLiveModel) Init() tea.Cmd { - err := ConnectToF1LiveTiming(m.dataChan) + err := live.ConnectToF1LiveTiming(m.dataChan) if err != nil { return func() tea.Msg { return err } } diff --git a/internal/web/live.go b/internal/web/live.go index 14d5564..c86d3e2 100644 --- a/internal/web/live.go +++ b/internal/web/live.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/AmanTahiliani/box-box/internal/ui" + "github.com/AmanTahiliani/box-box/internal/live" ) // sseClient is a connected SSE subscriber. @@ -30,7 +30,7 @@ type SSEHub struct { broadcast chan sseEvent mu sync.RWMutex - lastSnapshot *ui.LiveStreamData + lastSnapshot *live.LiveStreamData isLive bool } @@ -87,7 +87,7 @@ func formatSSEFrame(event string, data []byte) []byte { } // Snapshot returns the latest live data snapshot and whether a session is active. -func (h *SSEHub) Snapshot() (*ui.LiveStreamData, bool) { +func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) { h.mu.RLock() defer h.mu.RUnlock() return h.lastSnapshot, h.isLive @@ -134,9 +134,9 @@ func (s *Server) signalRLoop() { // connectAndDrain establishes a SignalR connection and drains the data channel // until the feed goes silent for 60 seconds. func (s *Server) connectAndDrain() error { - dataChan := make(chan ui.LiveStreamData, 16) + dataChan := make(chan live.LiveStreamData, 16) - if err := ui.ConnectToF1LiveTiming(dataChan); err != nil { + if err := live.ConnectToF1LiveTiming(dataChan); err != nil { return err } log.Printf("web: live feed connected")