Add local-first refactor foundation

This commit is contained in:
2026-05-25 00:45:46 -04:00
parent 161e871c53
commit 517c6b987b
45 changed files with 11545 additions and 697 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ build/
# Log files
*.log
.DS_Store
# SQLite database files
*.db

View File

@@ -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
}

View File

@@ -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?

View File

@@ -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 <meeting_key>`
- `--ingest-session <session_key>`
- `--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?

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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?

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,683 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>box-box — Command Center</title>
<link rel="stylesheet" href="styles.css">
<style>
/* ── Command Center Layout ── */
.cc-layout {
display: grid;
grid-template-columns: 220px 1fr 260px;
grid-template-rows: 1fr;
height: calc(100vh - 44px);
}
/* ── Left: Weekend Nav ── */
.cc-sidebar {
border-right: 1px solid var(--c-border);
padding: var(--s5);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--s5);
}
.weekend-badge {
display: flex;
align-items: center;
gap: var(--s2);
font-size: 11px;
color: var(--c-text-2);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: var(--s1);
}
.session-list { display: flex; flex-direction: column; gap: 1px; }
.session-row {
display: flex;
align-items: center;
gap: var(--s2);
padding: 7px var(--s3);
border-radius: 2px;
font-size: 12px;
cursor: pointer;
transition: background 0.1s;
}
.session-row:hover { background: var(--c-surface-2); }
.session-row.active { background: var(--c-surface-2); }
.session-row.current { border-left: 2px solid var(--c-red); padding-left: calc(var(--s3) - 2px); }
.session-name { flex: 1; color: var(--c-text-2); }
.session-name.done { color: var(--c-text-3); }
.session-time {
font-family: var(--f-mono);
font-size: 10px;
color: var(--c-text-3);
}
.year-switcher {
display: flex;
gap: 2px;
}
.year-btn {
padding: 3px 8px;
background: none;
border: 1px solid var(--c-border);
border-radius: 2px;
font-size: 11px;
color: var(--c-text-3);
transition: all 0.1s;
}
.year-btn.active {
background: var(--c-surface-2);
border-color: var(--c-border-2);
color: var(--c-text);
}
/* ── Center: Main Content ── */
.cc-main {
overflow-y: auto;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--sec-gap);
}
.race-hero {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.race-location {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.race-flag { font-size: 24px; line-height: 1; }
.race-name {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--c-text);
}
.race-meta {
font-size: 12px;
color: var(--c-text-3);
}
.race-round {
display: inline-block;
padding: 2px 7px;
border: 1px solid var(--c-border-2);
border-radius: 2px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--c-text-3);
}
.countdown-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.next-session-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--c-text-3);
}
.next-session-name {
font-size: 13px;
font-weight: 600;
color: var(--c-red);
}
.schedule-table th:first-child,
.schedule-table td:first-child { padding-left: 0; }
.session-status-cell {
display: flex;
align-items: center;
gap: 6px;
}
.weather-strip {
display: flex;
gap: var(--s5);
padding: var(--s3) var(--s4);
background: var(--c-surface);
border: 1px solid var(--c-border);
}
.wx-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.wx-label {
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-text-3);
}
.wx-val {
font-family: var(--f-mono);
font-size: 13px;
font-weight: 600;
color: var(--c-text);
}
/* ── Right: Championship + Data ── */
.cc-right {
border-left: 1px solid var(--c-border);
padding: var(--s5);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--s5);
}
.champ-tabs {
display: flex;
gap: 2px;
margin-bottom: var(--s3);
}
.champ-tab-btn {
padding: 3px 8px;
background: none;
border: 1px solid transparent;
border-radius: 2px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--c-text-3);
transition: all 0.1s;
}
.champ-tab-btn.active {
color: var(--c-text);
border-color: var(--c-border-2);
background: var(--c-surface-2);
}
.champ-table { width: 100%; font-size: 11px; }
.champ-row {
display: grid;
grid-template-columns: 20px 1fr 48px;
align-items: center;
gap: var(--s2);
padding: 5px 0;
border-bottom: 1px solid var(--c-border);
}
.champ-row:last-child { border-bottom: none; }
.champ-pos { font-family: var(--f-mono); color: var(--c-text-3); font-size: 11px; text-align: right; }
.champ-name { font-weight: 600; }
.champ-pts { font-family: var(--f-mono); font-weight: 700; text-align: right; }
.data-avail-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.avail-row {
display: flex;
align-items: center;
gap: var(--s2);
padding: 5px 0;
border-bottom: 1px solid var(--c-border);
font-size: 11px;
}
.avail-row:last-child { border-bottom: none; }
.avail-name { flex: 1; color: var(--c-text-2); }
.avail-count { font-family: var(--f-mono); font-size: 10px; color: var(--c-text-3); }
/* ── Responsive ── */
/* ── iPad: 2-col, hide left sidebar ── */
@media (min-width: 769px) and (max-width: 1100px) {
.cc-layout { grid-template-columns: 1fr 260px; }
.cc-sidebar { display: none; }
}
/* ── Phone: full-width stacked ── */
@media (max-width: 768px) {
.cc-layout { grid-template-columns: 1fr; grid-template-rows: auto; height: auto; }
.cc-sidebar { display: none; }
.cc-right { border-left: none; border-top: 1px solid var(--c-border); }
/* On phone, show session list inline inside main */
.cc-mobile-sessions { display: flex !important; }
}
</style>
</head>
<body>
<nav class="app-nav">
<a href="index.html" class="nav-logo">box<em>-</em>box</a>
<div class="nav-links">
<a href="command-center.html" class="active">Command Center</a>
<a href="live-timing.html">Live</a>
<a href="race-hub.html">Race Hub</a>
<a href="data-library.html">Data Library</a>
</div>
<div class="nav-right">
<div class="live-badge"><span class="live-dot"></span>LIVE</div>
<div class="density-toggle">
<button class="active" onclick="setDensity('default',this)">D</button>
<button onclick="setDensity('compact',this)">C</button>
</div>
</div>
</nav>
<div class="cc-layout">
<!-- ── Left: Weekend sessions ── -->
<aside class="cc-sidebar">
<div>
<div class="weekend-badge">
<span class="dot local"></span>
Monaco 2025
</div>
<div class="year-switcher" style="margin-bottom: var(--s4)">
<button class="year-btn active">2025</button>
<button class="year-btn">2024</button>
<button class="year-btn">2023</button>
</div>
<div class="session-list">
<div class="session-row">
<span class="dot local"></span>
<span class="session-name done">FP1</span>
<span class="session-time">Thu 11:30</span>
</div>
<div class="session-row">
<span class="dot local"></span>
<span class="session-name done">FP2</span>
<span class="session-time">Thu 15:00</span>
</div>
<div class="session-row">
<span class="dot local"></span>
<span class="session-name done">FP3</span>
<span class="session-time">Sat 11:30</span>
</div>
<div class="session-row">
<span class="dot local"></span>
<span class="session-name done">Qualifying</span>
<span class="session-time">Sat 15:00</span>
</div>
<div class="session-row current active">
<span class="dot live"></span>
<span class="session-name" style="color: var(--c-red); font-weight: 600;">Race</span>
<span class="session-time" style="color: var(--c-red);">LIVE</span>
</div>
</div>
</div>
<div>
<div class="sec-header">
<span class="sec-title">Upcoming</span>
</div>
<div class="session-list">
<div class="session-row">
<span class="dot upcoming"></span>
<span class="session-name done">Canada GP</span>
<span class="session-time">Jun 13</span>
</div>
<div class="session-row">
<span class="dot upcoming"></span>
<span class="session-name done">Austria GP</span>
<span class="session-time">Jun 27</span>
</div>
<div class="session-row">
<span class="dot upcoming"></span>
<span class="session-name done">British GP</span>
<span class="session-time">Jul 4</span>
</div>
</div>
</div>
</aside>
<!-- ── Center: Race weekend hero ── -->
<main class="cc-main">
<!-- Hero -->
<div class="race-hero">
<div style="display:flex; align-items:center; gap: var(--s3)">
<span class="race-round">Round 8</span>
<span class="badge live">
<span class="dot live"></span>
Race In Progress
</span>
<a href="live-timing.html" style="margin-left:auto; font-size:11px; color:var(--c-text-2); border:1px solid var(--c-border); padding: 3px 10px; border-radius:2px; font-weight:600; letter-spacing:0.04em;">Open Live →</a>
</div>
<div class="race-location">
<span class="race-flag">🇲🇨</span>
<div>
<div class="race-name">Monaco Grand Prix</div>
<div class="race-meta">Circuit de Monaco · Monte Carlo · 2225 May 2025</div>
</div>
</div>
</div>
<!-- Inline session list: visible on phone/iPad where sidebar is hidden -->
<div class="cc-mobile-sessions" style="display:none; gap:2px; flex-wrap:wrap; margin-top:var(--s1)">
<span style="font-size:10px; color:var(--c-text-3); margin-right:var(--s2); align-self:center; text-transform:uppercase; letter-spacing:.08em;">Weekend</span>
<span class="badge local">FP1 ✓</span>
<span class="badge local">FP2 ✓</span>
<span class="badge local">FP3 ✓</span>
<span class="badge local">Qual ✓</span>
<span class="badge live">Race ●</span>
</div>
<!-- Live race state / countdown if not live -->
<div style="padding: var(--s4); background: var(--c-surface); border: 1px solid var(--c-border); border-left: 3px solid var(--c-red);">
<div style="display:flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--s3)">
<span style="font-size:11px; font-weight:700; letter-spacing:0.1em; text-transform:uppercase; color: var(--c-red)">Race — In Progress</span>
<a href="live-timing.html" style="font-size:11px; color: var(--c-text-2); border: 1px solid var(--c-border); padding: 2px 8px; border-radius:2px;">Open Live Timing →</a>
</div>
<div style="display:grid; grid-template-columns: repeat(4, 1fr); gap: var(--s4)">
<div>
<div style="font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--c-text-3); margin-bottom:3px">Lap</div>
<div style="font-family: var(--f-mono); font-size:22px; font-weight:700; color: var(--c-text)">45<span style="font-size:14px; color: var(--c-text-3)">/78</span></div>
</div>
<div>
<div style="font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--c-text-3); margin-bottom:3px">Leader</div>
<div style="font-size:18px; font-weight:700; color: var(--t-fer)">LEC</div>
</div>
<div>
<div style="font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--c-text-3); margin-bottom:3px">Gap P1P2</div>
<div style="font-family: var(--f-mono); font-size:20px; font-weight:700; color:var(--c-text)">+3.4s</div>
</div>
<div>
<div style="font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--c-text-3); margin-bottom:3px">Track</div>
<div style="font-size:13px; font-weight:700;" class="track-green">● GREEN</div>
</div>
</div>
</div>
<!-- Schedule -->
<div>
<div class="sec-header">
<span class="sec-title">Weekend Schedule</span>
<span class="sec-meta">Circuit de Monaco · 3.337 km · 78 laps</span>
</div>
<table class="data-table schedule-table">
<thead>
<tr>
<th>Session</th>
<th>Date</th>
<th>Local Time</th>
<th>UTC</th>
<th>Status</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr>
<td><span style="font-weight:600">FP1</span></td>
<td class="t3">Thu 22 May</td>
<td class="mono t2">11:30</td>
<td class="mono t3">09:30</td>
<td><span class="badge local">Done</span></td>
<td><span class="badge local">Full</span></td>
</tr>
<tr>
<td><span style="font-weight:600">FP2</span></td>
<td class="t3">Thu 22 May</td>
<td class="mono t2">15:00</td>
<td class="mono t3">13:00</td>
<td><span class="badge local">Done</span></td>
<td><span class="badge local">Full</span></td>
</tr>
<tr>
<td><span style="font-weight:600">FP3</span></td>
<td class="t3">Sat 24 May</td>
<td class="mono t2">11:30</td>
<td class="mono t3">09:30</td>
<td><span class="badge local">Done</span></td>
<td><span class="badge local">Full</span></td>
</tr>
<tr>
<td><span style="font-weight:600">Qualifying</span></td>
<td class="t3">Sat 24 May</td>
<td class="mono t2">15:00</td>
<td class="mono t3">13:00</td>
<td><span class="badge local">Done</span></td>
<td><span class="badge local">Full</span></td>
</tr>
<tr>
<td><span style="font-weight:600; color: var(--c-red)">Race</span></td>
<td class="t3">Sun 25 May</td>
<td class="mono" style="color:var(--c-red)">14:00</td>
<td class="mono t3">12:00</td>
<td><span class="badge live">Live</span></td>
<td><span class="badge live">Streaming</span></td>
</tr>
</tbody>
</table>
</div>
<!-- Weather -->
<div>
<div class="sec-header">
<span class="sec-title">Weather</span>
<span class="sec-meta">Race day · updated 2 min ago</span>
</div>
<div class="weather-strip">
<div class="wx-item">
<span class="wx-label">Air</span>
<span class="wx-val">24°C</span>
</div>
<div class="wx-item">
<span class="wx-label">Track</span>
<span class="wx-val">36°C</span>
</div>
<div class="wx-item">
<span class="wx-label">Humidity</span>
<span class="wx-val">62%</span>
</div>
<div class="wx-item">
<span class="wx-label">Wind</span>
<span class="wx-val">7 km/h</span>
</div>
<div class="wx-item">
<span class="wx-label">Direction</span>
<span class="wx-val">NW</span>
</div>
<div class="wx-item">
<span class="wx-label">Rain Risk</span>
<span class="wx-val t-green">2%</span>
</div>
<div class="wx-item">
<span class="wx-label">Pressure</span>
<span class="wx-val">1014 hPa</span>
</div>
</div>
</div>
</main>
<!-- ── Right: Championship + Data ── -->
<aside class="cc-right">
<!-- Championship -->
<div>
<div class="sec-header">
<span class="sec-title">Championship</span>
<span class="sec-meta">After R7</span>
</div>
<div class="champ-tabs">
<button class="champ-tab-btn active" onclick="switchChamp('drivers', this)">Drivers</button>
<button class="champ-tab-btn" onclick="switchChamp('constructors', this)">Constructors</button>
</div>
<div id="champ-drivers">
<div class="champ-row">
<span class="champ-pos">1</span>
<span class="champ-name" style="color: var(--t-rb)">Verstappen</span>
<span class="champ-pts">138</span>
</div>
<div class="champ-row">
<span class="champ-pos">2</span>
<span class="champ-name" style="color: var(--t-mcl)">Norris</span>
<span class="champ-pts">116</span>
</div>
<div class="champ-row">
<span class="champ-pos">3</span>
<span class="champ-name" style="color: var(--t-fer)">Leclerc</span>
<span class="champ-pts">98</span>
</div>
<div class="champ-row">
<span class="champ-pos">4</span>
<span class="champ-name" style="color: var(--t-mcl)">Piastri</span>
<span class="champ-pts">86</span>
</div>
<div class="champ-row">
<span class="champ-pos">5</span>
<span class="champ-name" style="color: var(--t-mer)">Russell</span>
<span class="champ-pts">74</span>
</div>
<div class="champ-row">
<span class="champ-pos">6</span>
<span class="champ-name" style="color: var(--t-fer)">Hamilton</span>
<span class="champ-pts">62</span>
</div>
<div class="champ-row">
<span class="champ-pos">7</span>
<span class="champ-name" style="color: var(--t-am)">Alonso</span>
<span class="champ-pts">41</span>
</div>
<div class="champ-row">
<span class="champ-pos">8</span>
<span class="champ-name" style="color: var(--t-wil)">Sainz</span>
<span class="champ-pts">34</span>
</div>
</div>
<div id="champ-constructors" style="display:none">
<div class="champ-row">
<span class="champ-pos">1</span>
<span class="champ-name" style="color: var(--t-mcl)">McLaren</span>
<span class="champ-pts">202</span>
</div>
<div class="champ-row">
<span class="champ-pos">2</span>
<span class="champ-name" style="color: var(--t-rb)">Red Bull</span>
<span class="champ-pts">186</span>
</div>
<div class="champ-row">
<span class="champ-pos">3</span>
<span class="champ-name" style="color: var(--t-fer)">Ferrari</span>
<span class="champ-pts">162</span>
</div>
<div class="champ-row">
<span class="champ-pos">4</span>
<span class="champ-name" style="color: var(--t-mer)">Mercedes</span>
<span class="champ-pts">136</span>
</div>
<div class="champ-row">
<span class="champ-pos">5</span>
<span class="champ-name" style="color: var(--t-am)">Aston Martin</span>
<span class="champ-pts">54</span>
</div>
</div>
</div>
<!-- Data availability -->
<div>
<div class="sec-header">
<span class="sec-title">Local Data</span>
<a href="data-library.html" style="font-size:10px; color: var(--c-text-3); margin-left: auto">View all →</a>
</div>
<div class="data-avail-list">
<div class="avail-row">
<span class="dot local"></span>
<span class="avail-name">Bahrain GP</span>
<span class="avail-count t-green">Full</span>
</div>
<div class="avail-row">
<span class="dot local"></span>
<span class="avail-name">Saudi Arabia GP</span>
<span class="avail-count t-green">Full</span>
</div>
<div class="avail-row">
<span class="dot local"></span>
<span class="avail-name">Australian GP</span>
<span class="avail-count t-green">Full</span>
</div>
<div class="avail-row">
<span class="dot partial"></span>
<span class="avail-name">Japanese GP</span>
<span class="avail-count t-yellow">Partial</span>
</div>
<div class="avail-row">
<span class="dot partial"></span>
<span class="avail-name">Chinese GP</span>
<span class="avail-count t-yellow">Partial</span>
</div>
<div class="avail-row">
<span class="dot local"></span>
<span class="avail-name">Miami GP</span>
<span class="avail-count t-green">Full</span>
</div>
<div class="avail-row">
<span class="dot local"></span>
<span class="avail-name">Emilia Romagna GP</span>
<span class="avail-count t-green">Full</span>
</div>
<div class="avail-row">
<span class="dot live"></span>
<span class="avail-name">Monaco GP</span>
<span class="avail-count t-red">Live</span>
</div>
</div>
<div style="margin-top: var(--s3); padding: var(--s3) var(--s3); background: var(--c-surface-2); border: 1px solid var(--c-border); font-size: 10px; color: var(--c-text-3)">
<span style="color: var(--c-yellow)"></span>
2 weekends have partial data.
<a href="data-library.html" style="color: var(--c-text-2); text-decoration: underline; text-underline-offset: 2px;">View ingest commands</a>
</div>
</div>
</aside>
</div>
<script>
function setDensity(mode, btn) {
document.querySelectorAll('.density-toggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.documentElement.classList.toggle('compact', mode === 'compact');
}
function switchChamp(tab, btn) {
document.querySelectorAll('.champ-tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('champ-drivers').style.display = tab === 'drivers' ? '' : 'none';
document.getElementById('champ-constructors').style.display = tab === 'constructors' ? '' : 'none';
}
</script>
</body>
</html>

View File

@@ -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 — `<AppNav>`
**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 — `<DensityToggle>`
**Purpose**: Switches between default and compact row heights. Effect is applied as a CSS class on `<html>`, not via React state cascade.
**Appears in**: `<AppNav>`.
**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 — `<SessionBanner>`
**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 — `<TimingTower>`
**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 12 seconds. Memoize `<TimingRow>` 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 — `<RaceControlFeed>`
**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 — `<BattlesList>`
**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 — `<PinnedStrip>`
**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 — `<PitWindowPanel>`
**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 — `<StrategyChart>`
**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 — `<PositionEvolution>`
**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 — `<DatasetStatus>`
**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 — `<IngestCommandBlock>`
**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 — `<PanelShell>`
**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 (7691024px): `main | aside` with narrower aside
- Phone (<768px): `main` full-width + bottom tab nav switching aside panels
**Implementation note**: Density mode class on `<html>` flows through without prop drilling. `PanelShell` does not need to know about density — CSS handles it.
---
## Source/Freshness Strip — `<SourceStrip>`
**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.

View File

@@ -0,0 +1,651 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>box-box — Data Library</title>
<link rel="stylesheet" href="styles.css">
<style>
/* ── Layout ── */
.dl-layout {
display: grid;
grid-template-columns: 200px 1fr;
height: calc(100vh - 44px);
overflow: hidden;
}
/* ── Left: Season nav ── */
.dl-nav {
border-right: 1px solid var(--c-border);
padding: var(--s5);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--s5);
}
.season-list { display: flex; flex-direction: column; gap: 1px; }
.season-row {
display: flex;
align-items: center;
gap: var(--s2);
padding: 7px var(--s3);
border-radius: 2px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
}
.season-row:hover { background: var(--c-surface-2); }
.season-row.active { background: var(--c-surface-2); color: var(--c-text); }
.season-row-count {
margin-left: auto;
font-size: 10px;
font-family: var(--f-mono);
color: var(--c-text-3);
}
/* Summary stats */
.dl-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
border: 1px solid var(--c-border);
}
.dl-stat {
padding: var(--s3) var(--s3);
background: var(--c-surface);
display: flex;
flex-direction: column;
gap: 2px;
}
.dl-stat-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-3);
}
.dl-stat-val {
font-family: var(--f-mono);
font-size: 16px;
font-weight: 700;
}
/* ── Right: Content ── */
.dl-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Content header */
.dl-content-header {
display: flex;
align-items: center;
gap: var(--s4);
padding: var(--s3) var(--s5);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
flex-wrap: wrap;
}
/* Content body split */
.dl-content-body {
display: grid;
grid-template-columns: 1fr 340px;
flex: 1;
overflow: hidden;
}
/* Round table */
.dl-round-scroll {
overflow-y: auto;
border-right: 1px solid var(--c-border);
}
.rounds-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.rounds-table th {
position: sticky;
top: 0;
padding: var(--s1) var(--pad-h);
background: var(--c-surface);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--c-text-3);
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
z-index: 10;
}
.rounds-table td {
padding: var(--pad-v) var(--pad-h);
border-bottom: 1px solid var(--c-border);
height: var(--row-h);
white-space: nowrap;
}
.rounds-table tbody tr { cursor: pointer; transition: background 0.08s; }
.rounds-table tbody tr:hover { background: var(--c-surface-2); }
.rounds-table tbody tr.active { background: var(--c-surface-3); }
.rounds-table tbody tr:last-child td { border-bottom: none; }
.session-icons {
display: flex;
gap: 3px;
align-items: center;
}
.si {
width: 14px;
height: 14px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 700;
font-family: var(--f-mono);
}
.si.full { background: rgba(0,204,106,0.2); color: var(--c-green); }
.si.partial { background: rgba(255,214,0,0.2); color: var(--c-yellow); }
.si.missing { background: rgba(50,50,50,0.5); color: var(--c-text-3); }
.si.live { background: rgba(225,6,0,0.2); color: var(--c-red); }
.si.future { background: rgba(40,40,40,0.5); color: var(--c-text-3); }
/* ── Detail panel ── */
.dl-detail {
overflow-y: auto;
padding: var(--s4);
display: flex;
flex-direction: column;
gap: var(--s4);
}
.detail-header {
display: flex;
flex-direction: column;
gap: var(--s2);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--c-border);
}
.detail-title {
font-size: 14px;
font-weight: 700;
}
.detail-meta {
font-size: 11px;
color: var(--c-text-3);
}
.session-detail-row {
margin-bottom: var(--s4);
}
.session-detail-head {
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s2);
font-size: 11px;
font-weight: 700;
color: var(--c-text-2);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.ingest-btn {
margin-left: auto;
padding: 2px 8px;
background: none;
border: 1px solid var(--c-border-2);
border-radius: 2px;
font-size: 10px;
font-weight: 600;
color: var(--c-text-3);
letter-spacing: 0.04em;
transition: all 0.1s;
text-transform: uppercase;
}
.ingest-btn:hover {
border-color: var(--c-green);
color: var(--c-green);
}
.dataset-list { display: flex; flex-direction: column; }
.ds-row {
display: flex;
align-items: center;
gap: var(--s2);
padding: 4px 0;
border-bottom: 1px solid var(--c-border);
font-size: 11px;
}
.ds-row:last-child { border-bottom: none; }
.ds-row-name { flex: 1; color: var(--c-text-2); font-family: var(--f-mono); font-size: 10px; }
.ds-row-time { font-family: var(--f-mono); font-size: 10px; color: var(--c-text-3); }
/* ── Responsive ── */
/* ── iPad ── */
@media (max-width: 900px) {
.dl-layout { grid-template-columns: 1fr; height: auto; overflow: auto; }
.dl-nav { border-right: none; border-bottom: 1px solid var(--c-border); padding: var(--s4); }
/* On iPad, show season as horizontal tabs */
.season-list { flex-direction: row; gap: var(--s2); }
.dl-content { height: auto; overflow: visible; }
.dl-content-body { grid-template-columns: 1fr; height: auto; overflow: visible; }
.dl-round-scroll { border-right: none; overflow: visible; }
.dl-detail { border-top: 1px solid var(--c-border); }
}
/* ── Phone ── */
@media (max-width: 480px) {
.dl-stats { grid-template-columns: repeat(4, 1fr); }
.dl-content-body { display: block; }
.dl-round-scroll .rounds-table td:nth-child(3),
.dl-round-scroll .rounds-table th:nth-child(3) { display: none; }
}
</style>
</head>
<body>
<nav class="app-nav">
<a href="index.html" class="nav-logo">box<em>-</em>box</a>
<div class="nav-links">
<a href="command-center.html">Command Center</a>
<a href="live-timing.html">Live</a>
<a href="race-hub.html">Race Hub</a>
<a href="data-library.html" class="active">Data Library</a>
</div>
<div class="nav-right">
<div class="density-toggle">
<button class="active" onclick="setDensity('default',this)">D</button>
<button onclick="setDensity('compact',this)">C</button>
</div>
</div>
</nav>
<div class="dl-layout">
<!-- ── Left: season nav ── -->
<aside class="dl-nav">
<div>
<div class="sec-header">
<span class="sec-title">Seasons</span>
</div>
<div class="season-list">
<div class="season-row active" onclick="selectSeason(this, '2025')">
<span class="dot local"></span>
<span>2025</span>
<span class="season-row-count">7 / 24</span>
</div>
<div class="season-row" onclick="selectSeason(this, '2024')">
<span class="dot local"></span>
<span>2024</span>
<span class="season-row-count">24 / 24</span>
</div>
<div class="season-row" onclick="selectSeason(this, '2023')">
<span class="dot missing"></span>
<span>2023</span>
<span class="season-row-count">0 / 22</span>
</div>
</div>
</div>
<div>
<div class="sec-header">
<span class="sec-title">Storage</span>
</div>
<div style="font-size:11px; display:flex; flex-direction:column; gap:5px;">
<div class="flex ai-c gap-2">
<span class="t3">Database</span>
<span class="mono t2" style="margin-left:auto">~/.cache/box-box/db.sqlite</span>
</div>
<div class="flex ai-c gap-2">
<span class="t3">Size</span>
<span class="mono t2" style="margin-left:auto">142 MB</span>
</div>
<div class="flex ai-c gap-2">
<span class="t3">Raw payloads</span>
<span class="mono t2" style="margin-left:auto">89 MB</span>
</div>
</div>
</div>
<div>
<div class="dl-stats">
<div class="dl-stat">
<span class="dl-stat-label">Full</span>
<span class="dl-stat-val t-green">7</span>
</div>
<div class="dl-stat">
<span class="dl-stat-label">Partial</span>
<span class="dl-stat-val t-yellow">2</span>
</div>
<div class="dl-stat">
<span class="dl-stat-label">Missing</span>
<span class="dl-stat-val t3">15</span>
</div>
<div class="dl-stat">
<span class="dl-stat-label">Live</span>
<span class="dl-stat-val t-red">1</span>
</div>
</div>
</div>
</aside>
<!-- ── Right: content ── -->
<div class="dl-content">
<!-- Content header -->
<div class="dl-content-header">
<span style="font-size:13px; font-weight:700">2025 Season</span>
<span class="t3" style="font-size:12px">24 rounds · 7 complete · 2 partial · 1 live</span>
<div style="margin-left:auto; display:flex; gap:var(--s2)">
<select style="background:var(--c-surface-2); border:1px solid var(--c-border); border-radius:2px; color:var(--c-text-2); font-size:11px; padding:3px 6px; font-family:var(--f-ui)">
<option>All rounds</option>
<option>Complete only</option>
<option>Partial / missing</option>
</select>
</div>
</div>
<div class="dl-content-body">
<!-- Round table -->
<div class="dl-round-scroll">
<table class="rounds-table">
<thead>
<tr>
<th style="width:32px">Rnd</th>
<th>Weekend</th>
<th>Date</th>
<th>Status</th>
<th class="hide-mobile">Sessions</th>
<th class="hide-mobile">Last Sync</th>
</tr>
</thead>
<tbody>
<tr onclick="selectRound(this,'bahrain')" class="active">
<td class="mono t3" style="text-align:center">1</td>
<td><span style="font-weight:600">🇧🇭 Bahrain GP</span></td>
<td class="mono t3">2 Mar</td>
<td><span class="badge local"><span class="dot local"></span>Full</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si full">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">2d ago</td>
</tr>
<tr onclick="selectRound(this,'saudi')">
<td class="mono t3" style="text-align:center">2</td>
<td><span style="font-weight:600">🇸🇦 Saudi Arabia GP</span></td>
<td class="mono t3">16 Mar</td>
<td><span class="badge local"><span class="dot local"></span>Full</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si full">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">5d ago</td>
</tr>
<tr onclick="selectRound(this,'australia')">
<td class="mono t3" style="text-align:center">3</td>
<td><span style="font-weight:600">🇦🇺 Australian GP</span></td>
<td class="mono t3">30 Mar</td>
<td><span class="badge local"><span class="dot local"></span>Full</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si full">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">8d ago</td>
</tr>
<tr onclick="selectRound(this,'japan')">
<td class="mono t3" style="text-align:center">4</td>
<td><span style="font-weight:600">🇯🇵 Japanese GP</span></td>
<td class="mono t3">13 Apr</td>
<td><span class="badge partial"><span class="dot partial"></span>Partial</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si partial">F3</div>
<div class="si full">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">21d ago</td>
</tr>
<tr onclick="selectRound(this,'china')">
<td class="mono t3" style="text-align:center">5</td>
<td><span style="font-weight:600">🇨🇳 Chinese GP</span></td>
<td class="mono t3">20 Apr</td>
<td><span class="badge partial"><span class="dot partial"></span>Partial</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si missing">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">28d ago</td>
</tr>
<tr onclick="selectRound(this,'miami')">
<td class="mono t3" style="text-align:center">6</td>
<td><span style="font-weight:600">🇺🇸 Miami GP</span></td>
<td class="mono t3">4 May</td>
<td><span class="badge local"><span class="dot local"></span>Full</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si full">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">14d ago</td>
</tr>
<tr onclick="selectRound(this,'imola')">
<td class="mono t3" style="text-align:center">7</td>
<td><span style="font-weight:600">🇮🇹 Emilia Romagna GP</span></td>
<td class="mono t3">18 May</td>
<td><span class="badge local"><span class="dot local"></span>Full</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si full">Q</div>
<div class="si full">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">3d ago</td>
</tr>
<tr onclick="selectRound(this,'monaco')">
<td class="mono t3" style="text-align:center">8</td>
<td><span style="font-weight:600">🇲🇨 Monaco GP</span></td>
<td class="mono t3">25 May</td>
<td><span class="badge live"><span class="dot live"></span>Live</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si full">F1</div>
<div class="si full">F2</div>
<div class="si full">F3</div>
<div class="si full">Q</div>
<div class="si live">R</div>
</div>
</td>
<td class="mono t3 hide-mobile">Now</td>
</tr>
<tr onclick="selectRound(this,'canada')">
<td class="mono t3" style="text-align:center">9</td>
<td><span class="t3">🇨🇦 Canadian GP</span></td>
<td class="mono t3">13 Jun</td>
<td><span class="badge missing"><span class="dot missing"></span>Missing</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si future">F1</div>
<div class="si future">F2</div>
<div class="si future">F3</div>
<div class="si future">Q</div>
<div class="si future">R</div>
</div>
</td>
<td class="mono t3 hide-mobile"></td>
</tr>
<tr>
<td class="mono t3" style="text-align:center">10</td>
<td><span class="t3">🇦🇹 Austrian GP</span></td>
<td class="mono t3">27 Jun</td>
<td><span class="badge missing"><span class="dot missing"></span>Missing</span></td>
<td class="hide-mobile">
<div class="session-icons">
<div class="si future">F1</div>
<div class="si future">F2</div>
<div class="si future">F3</div>
<div class="si future">Q</div>
<div class="si future">R</div>
</div>
</td>
<td class="mono t3 hide-mobile"></td>
</tr>
<tr>
<td class="mono t3" style="text-align:center" colspan="6" style="text-align:left; padding: var(--s3) var(--pad-h); color:var(--c-text-3); font-size:11px">
+ 14 upcoming rounds not yet available
</td>
</tr>
</tbody>
</table>
</div>
<!-- Detail panel -->
<div class="dl-detail" id="detail-panel">
<div class="detail-header">
<div style="display:flex; align-items:center; gap:var(--s2)">
<span class="detail-title">🇧🇭 Bahrain Grand Prix</span>
<span class="badge local">Full</span>
</div>
<div class="detail-meta">Round 1 · 2 March 2025 · meeting_key 1230</div>
<div class="detail-meta">Ingested: 2 Mar 2025 18:42 · 142 MB across 5 sessions</div>
</div>
<!-- FP1 -->
<div class="session-detail-row">
<div class="session-detail-head">
<span class="dot local"></span> FP1
<span class="t3" style="font-weight:400; text-transform:none; letter-spacing:0">session_key 9120</span>
</div>
<div class="dataset-list">
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">laps</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">stints</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">weather</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot missing"></span><span class="ds-row-name">car_data_samples</span><span class="ds-row-time t3">not ingested</span></div>
</div>
</div>
<!-- Race -->
<div class="session-detail-row">
<div class="session-detail-head">
<span class="dot local"></span> Race
<span class="t3" style="font-weight:400; text-transform:none; letter-spacing:0">session_key 9125</span>
<button class="ingest-btn">↓ Refresh</button>
</div>
<div class="dataset-list">
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">session_results</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">starting_grid</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">laps</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">stints</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">pit_stops</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">positions</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">race_control</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot local"></span><span class="ds-row-name">weather</span><span class="ds-row-time">2d ago</span></div>
<div class="ds-row"><span class="dot missing"></span><span class="ds-row-name">car_data_samples</span><span class="ds-row-time t3">not ingested</span></div>
<div class="ds-row"><span class="dot missing"></span><span class="ds-row-name">location_samples</span><span class="ds-row-time t3">not ingested</span></div>
<div class="ds-row"><span class="dot missing"></span><span class="ds-row-name">team_radio</span><span class="ds-row-time t3">not ingested</span></div>
</div>
</div>
<!-- CLI -->
<div>
<div class="sec-header">
<span class="sec-title">Ingest Commands</span>
</div>
<div class="cli-block">
<div class="comment"># Full weekend ingest (all sessions, default datasets)</div>
<div class="cmd">box-box --ingest-meeting 1230</div>
<br>
<div class="comment"># Race session only</div>
<div class="cmd">box-box --ingest-session 9125</div>
<br>
<div class="comment"># High-volume telemetry (explicit, large download)</div>
<div class="cmd">box-box --ingest-session 9125 --datasets car_data,location</div>
<br>
<div class="comment"># Preview without downloading</div>
<div class="cmd">box-box --ingest-meeting 1230 --dry-run</div>
</div>
</div>
</div><!-- /dl-detail -->
</div><!-- /dl-content-body -->
</div><!-- /dl-content -->
</div><!-- /dl-layout -->
<script>
function setDensity(mode, btn) {
document.querySelectorAll('.density-toggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.documentElement.classList.toggle('compact', mode === 'compact');
}
function selectSeason(el, season) {
document.querySelectorAll('.season-row').forEach(r => r.classList.remove('active'));
el.classList.add('active');
}
function selectRound(row, id) {
document.querySelectorAll('.rounds-table tbody tr').forEach(r => r.classList.remove('active'));
row.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>box-box — Screen Index</title>
<link rel="stylesheet" href="styles.css">
<style>
.index-layout {
max-width: 680px;
margin: 60px auto;
padding: 0 var(--s5);
}
.index-header {
margin-bottom: var(--s8);
}
.index-title {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.index-title em { color: var(--c-red); font-style: normal; }
.index-subtitle {
margin-top: var(--s2);
font-size: 12px;
color: var(--c-text-3);
}
.screen-list {
display: flex;
flex-direction: column;
gap: 1px;
border: 1px solid var(--c-border);
}
.screen-item {
display: grid;
grid-template-columns: 200px 1fr auto;
align-items: center;
gap: var(--s4);
padding: var(--s4) var(--s5);
background: var(--c-surface);
transition: background 0.1s;
}
.screen-item:hover { background: var(--c-surface-2); }
.screen-name {
font-size: 13px;
font-weight: 600;
color: var(--c-text);
}
.screen-desc {
font-size: 11px;
color: var(--c-text-3);
line-height: 1.5;
}
.screen-link {
font-family: var(--f-mono);
font-size: 10px;
color: var(--c-text-3);
padding: 3px 8px;
border: 1px solid var(--c-border);
border-radius: 2px;
white-space: nowrap;
}
.screen-item:hover .screen-link {
border-color: var(--c-border-2);
color: var(--c-text-2);
}
.index-note {
margin-top: var(--s5);
padding: var(--s4);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-left: 3px solid var(--c-border-2);
font-size: 11px;
color: var(--c-text-3);
line-height: 1.7;
}
@media (max-width: 600px) {
.screen-item { grid-template-columns: 1fr; gap: var(--s2); }
.screen-link { display: none; }
}
</style>
</head>
<body>
<nav class="app-nav">
<a href="index.html" class="nav-logo">box<em>-</em>box</a>
<div class="nav-links">
<a href="index.html" class="active">Screens</a>
</div>
<div class="nav-right">
<div class="density-toggle">
<button class="active" onclick="setDensity('default', this)">D</button>
<button onclick="setDensity('compact', this)">C</button>
</div>
</div>
</nav>
<div class="index-layout">
<div class="index-header">
<div class="index-title">box<em>-</em>box — Static Mockups</div>
<div class="index-subtitle">
Visual / product validation screens. Static HTML only, no build tooling. Open directly in a browser.
</div>
</div>
<div class="screen-list">
<a href="command-center.html" class="screen-item">
<span class="screen-name">Command Center</span>
<span class="screen-desc">
Landing screen. Current race weekend, countdown, schedule, championship snapshot, data status.
</span>
<span class="screen-link">command-center.html</span>
</a>
<a href="race-hub.html" class="screen-item">
<span class="screen-name">Race Hub</span>
<span class="screen-desc">
Completed race analysis. Classification, strategy chart, position evolution, race control, weather, dataset status.
</span>
<span class="screen-link">race-hub.html</span>
</a>
<a href="live-timing.html" class="screen-item">
<span class="screen-name">Live Timing</span>
<span class="screen-desc">
Active session screen. Timing tower, track status, race control feed, battles, fastest lap strip.
</span>
<span class="screen-link">live-timing.html</span>
</a>
<a href="data-library.html" class="screen-item">
<span class="screen-name">Data Library</span>
<span class="screen-desc">
Local data transparency. Season/weekend ingestion status, missing datasets, CLI commands.
</span>
<span class="screen-link">data-library.html</span>
</a>
<a href="mobile-live.html" class="screen-item" style="border-top: 1px solid var(--c-border-2);">
<span class="screen-name">
Mobile Live
<span style="font-size:10px; font-weight:500; color:var(--c-text-3); margin-left:8px;">layout demo</span>
</span>
<span class="screen-desc">
Dedicated phone layout for Live Timing. Bottom tab bar, full-screen panels. Press D to toggle disconnected state; S to cycle track status.
</span>
<span class="screen-link">mobile-live.html</span>
</a>
</div>
<div class="index-note">
Design direction: <strong style="color: var(--c-text-2)">F1 Ops Room.</strong>
Dense where useful, fast to scan, team color as data not decoration.
No gradient hero panels, no floating card sludge, no decorative chrome.
Responsive: phone-first for live screens, desktop-rich for analysis.
</div>
</div>
<script>
function setDensity(mode, btn) {
document.querySelectorAll('.density-toggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.documentElement.classList.toggle('compact', mode === 'compact');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,774 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>box-box — Live Timing · Monaco GP Race</title>
<link rel="stylesheet" href="styles.css">
<style>
/* ── Full-height root ── */
html, body { height: 100%; overflow: hidden; }
.lt-root {
display: flex;
flex-direction: column;
height: calc(100vh - 44px);
}
/* ── Session Banner ── */
.lt-banner {
display: flex;
align-items: center;
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;
flex-shrink: 0;
}
.bi {
display: flex;
align-items: center;
gap: var(--s2);
padding: 0 var(--s4);
border-right: 1px solid var(--c-border);
flex-shrink: 0;
height: 100%;
}
.bi:first-child { padding-left: 0; }
.bi-label { font-size: 9px; font-family: var(--f-ui); letter-spacing: 0.08em; text-transform: uppercase; color: var(--c-text-3); }
.bi-val { font-weight: 600; color: var(--c-text); }
/* ── Pinned strip ── */
.lt-pinned {
display: flex;
align-items: center;
gap: var(--s1);
padding: 0 var(--s4);
height: 36px;
background: var(--c-surface-2);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
overflow-x: auto;
}
.lt-pinned-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-text-3);
margin-right: var(--s2);
flex-shrink: 0;
}
.pin-card {
display: flex;
align-items: center;
gap: var(--s2);
padding: 4px var(--s3);
background: var(--c-surface);
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); }
.pin-last { font-family: var(--f-mono); font-size: 10px; color: var(--c-text-3); }
/* ── Main body ── */
.lt-body {
display: grid;
grid-template-columns: 1fr 300px;
flex: 1;
overflow: hidden;
}
/* ── Tower pane ── */
.lt-tower {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--c-border);
}
.lt-tower-scroll { overflow-y: auto; flex: 1; }
/* Timing table */
.tt-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.tt-table th {
position: sticky;
top: 0;
padding: var(--s1) var(--s2);
background: var(--c-surface);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--c-text-3);
border-bottom: 1px solid var(--c-border);
text-align: right;
white-space: nowrap;
z-index: 10;
}
.tt-table th.left { text-align: left; }
.tt-table td {
padding: var(--pad-v) var(--s2);
border-bottom: 1px solid var(--c-border);
height: var(--row-h);
text-align: right;
white-space: nowrap;
font-family: var(--f-mono);
font-size: 12px;
}
.tt-table td.left { text-align: left; font-family: var(--f-ui); }
.tt-table tbody tr:hover { background: var(--c-surface-2); }
.tt-pos { font-size: 14px; font-weight: 700; text-align: center; width: 28px; }
.tt-gap { color: var(--c-text-2); min-width: 68px; }
.tt-int { color: var(--c-text-3); min-width: 64px; }
.tt-age { color: var(--c-text-3); min-width: 28px; }
.tt-last { color: var(--c-text-2); min-width: 74px; }
.tt-best { color: var(--c-text-3); min-width: 74px; }
.tt-table tr.fl .tt-best { color: var(--c-purple); font-weight: 700; }
.tt-table tr.pit td { background: rgba(255,214,0,0.04); }
.tt-table tr.pit .tt-gap { color: var(--c-yellow); }
/* Sector dots */
.sectors { display: flex; gap: 2px; align-items: center; }
.s-dot { width: 6px; height: 6px; border-radius: 1px; }
.s-dot.pb { background: var(--c-green); }
.s-dot.ob { background: var(--c-purple); }
.s-dot.sl { background: var(--c-yellow); }
.s-dot.nor { background: var(--c-border-2); }
/* ── Right sidebar ── */
.lt-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
}
.sb-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-bottom: 1px solid var(--c-border);
}
.sb-panel:last-child { border-bottom: none; }
.sb-head {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s3);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
}
.sb-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-text-3);
}
.sb-body { flex: 1; overflow-y: auto; }
/* RC in sidebar */
.rc-side-msg {
display: grid;
grid-template-columns: 44px 1fr;
gap: var(--s2);
padding: 5px var(--s3);
border-bottom: 1px solid var(--c-border);
font-size: 11px;
}
.rc-side-msg:last-child { border-bottom: none; }
.rc-side-lap { font-family: var(--f-mono); color: var(--c-text-3); padding-top: 1px; }
.rc-side-text { color: var(--c-text-2); line-height: 1.4; }
.rc-side-msg.sc .rc-side-text { color: var(--c-yellow); font-weight: 600; }
.rc-side-msg.drs .rc-side-text { color: var(--c-green); }
.rc-side-msg.fl .rc-side-text { color: var(--c-purple); }
.rc-side-msg.flag .rc-side-text { color: var(--c-red); font-weight: 600; }
/* Battles in sidebar */
.battle-row {
display: flex;
align-items: center;
gap: var(--s2);
padding: 6px var(--s3);
border-bottom: 1px solid var(--c-border);
font-size: 11px;
}
.battle-row:last-child { border-bottom: none; }
.battle-drivers { display: flex; align-items: center; gap: 4px; flex: 1; }
.battle-gap { font-family: var(--f-mono); font-size: 12px; font-weight: 700; }
.battle-trend { font-size: 10px; font-family: var(--f-mono); }
/* ── Footer ── */
.lt-footer {
display: flex;
align-items: center;
gap: var(--s5);
height: 36px;
padding: 0 var(--s5);
background: var(--c-surface);
border-top: 1px solid var(--c-border);
font-size: 11px;
flex-shrink: 0;
overflow-x: auto;
}
.fl-item { display: flex; align-items: center; gap: var(--s2); flex-shrink: 0; }
.fl-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--c-text-3); }
/* ── RESPONSIVE: phone ── */
@media (max-width: 768px) {
html, body { height: auto; overflow: auto; }
.lt-root {
height: auto;
min-height: calc(100vh - 44px);
padding-bottom: 58px;
}
.lt-pinned { display: none; } /* reduce clutter on phone */
.lt-body {
grid-template-columns: 1fr;
flex: none;
overflow: visible;
}
.lt-tower {
border-right: none;
overflow: visible;
flex: none;
}
.lt-tower-scroll { overflow: visible; flex: none; }
.lt-sidebar { display: none; } /* replaced by phone panels */
.lt-footer { display: none; }
/* Phone panels */
.phone-panel {
display: none;
padding: var(--s3);
border-top: 1px solid var(--c-border);
}
.phone-panel.active { display: block; }
.phone-tab-bar { display: flex; }
}
/* ── RESPONSIVE: iPad ── */
@media (min-width: 769px) and (max-width: 1024px) {
.lt-body { grid-template-columns: 1fr 260px; }
.hide-mobile { display: none !important; }
}
</style>
</head>
<body>
<nav class="app-nav">
<a href="index.html" class="nav-logo">box<em>-</em>box</a>
<div class="nav-links">
<a href="command-center.html">Command Center</a>
<a href="live-timing.html" class="active">Live</a>
<a href="race-hub.html">Race Hub</a>
<a href="data-library.html">Data Library</a>
</div>
<div class="nav-right">
<div class="live-badge"><span class="live-dot"></span>RACE</div>
<div class="density-toggle">
<button class="active" onclick="setDensity('default',this)">D</button>
<button onclick="setDensity('compact',this)">C</button>
</div>
</div>
</nav>
<div class="lt-root">
<!-- ── Session Banner ── -->
<div class="lt-banner">
<div class="bi">
<span class="bi-label">Session</span>
<span class="bi-val">Monaco GP — Race</span>
</div>
<div class="bi">
<span class="bi-label">Lap</span>
<span class="bi-val">45 <span style="color:var(--c-text-3);font-size:11px">/ 78</span></span>
</div>
<div class="bi">
<span class="bi-label">Clock</span>
<span class="bi-val mono">1:02:34</span>
</div>
<div class="bi">
<span class="bi-label">Track</span>
<span class="bi-val track-green">● GREEN</span>
</div>
<div class="bi">
<span class="bi-label">DRS</span>
<span class="bi-val t-green">ENABLED</span>
</div>
<div class="bi">
<span class="bi-label">Air / Track</span>
<span class="bi-val t2">25°C / 38°C</span>
</div>
<div class="bi">
<span class="bi-label">Fastest Lap</span>
<span class="bi-val" style="color:var(--c-purple)">NOR 1:14.756</span>
</div>
</div>
<!-- ── Pinned drivers strip ── -->
<div class="lt-pinned">
<span class="lt-pinned-label">Pinned</span>
<div class="pin-card">
<span class="pin-pos">P1</span>
<div class="drv-cell" style="gap:5px">
<div class="drv-bar" style="background:var(--t-fer);height:14px"></div>
<span class="drv-code" style="font-size:12px;color:var(--t-fer)">LEC</span>
</div>
<span class="pin-gap">LEADER</span>
<span class="pin-last">1:15.234</span>
</div>
<div class="pin-card">
<span class="pin-pos">P3</span>
<div class="drv-cell" style="gap:5px">
<div class="drv-bar" style="background:var(--t-mcl);height:14px"></div>
<span class="drv-code" style="font-size:12px;color:var(--t-mcl)">NOR</span>
</div>
<span class="pin-gap">+7.2</span>
<span class="pin-last" style="color:var(--c-purple)">FL 1:14.756</span>
</div>
<div class="pin-card">
<span class="pin-pos">P8</span>
<div class="drv-cell" style="gap:5px">
<div class="drv-bar" style="background:var(--t-wil);height:14px"></div>
<span class="drv-code" style="font-size:12px;color:var(--t-wil)">SAI</span>
</div>
<span class="pin-gap t-yellow">PIT OUT</span>
<span class="pin-last">1:41.678</span>
</div>
<span style="margin-left:var(--s3);font-size:10px;color:var(--c-text-3)">Pin drivers with P in TUI or browser shortcut</span>
</div>
<!-- ── Main body ── -->
<div class="lt-body">
<!-- ── Timing Tower ── -->
<div class="lt-tower" id="panel-tower">
<div class="lt-tower-scroll">
<table class="tt-table">
<thead>
<tr>
<th class="left" style="width:28px;padding-left:var(--s4)">P</th>
<th class="left" style="min-width:110px">Driver</th>
<th>Gap</th>
<th>Int</th>
<th style="text-align:center;width:26px">Tyre</th>
<th>Age</th>
<th>Last</th>
<th>Best</th>
<th class="hide-mobile" style="text-align:left">S1 S2 S3</th>
</tr>
</thead>
<tbody>
<tr class="fl">
<td class="left tt-pos" style="padding-left:var(--s4)">1</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-fer)"></div><span class="drv-code">LEC</span><span class="drv-num">16</span></div></td>
<td class="tt-gap" style="color:var(--c-text-3);font-size:11px">LEADER</td>
<td class="tt-int"></td>
<td style="text-align:center"><span class="tyre M">M</span></td>
<td class="tt-age">12</td>
<td class="tt-last">1:15.234</td>
<td class="tt-best" style="color:var(--c-text-2)">1:14.892</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot pb"></div><div class="s-dot pb"></div><div class="s-dot sl"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">2</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-rb)"></div><span class="drv-code">VER</span><span class="drv-num">1</span></div></td>
<td class="tt-gap">+3.456</td>
<td class="tt-int">+3.456</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age">8</td>
<td class="tt-last">1:15.623</td>
<td class="tt-best">1:15.023</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot pb"></div><div class="s-dot nor"></div></div></td>
</tr>
<tr class="fl">
<td class="left tt-pos" style="padding-left:var(--s4)">3</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-mcl)"></div><span class="drv-code">NOR</span><span class="drv-num">4</span></div></td>
<td class="tt-gap">+7.234</td>
<td class="tt-int">+3.778</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age">28</td>
<td class="tt-last">1:15.012</td>
<td class="tt-best">1:14.756</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot ob"></div><div class="s-dot ob"></div><div class="s-dot ob"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">4</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-mcl)"></div><span class="drv-code">PIA</span><span class="drv-num">81</span></div></td>
<td class="tt-gap">+12.567</td>
<td class="tt-int">+5.333</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age">22</td>
<td class="tt-last">1:15.890</td>
<td class="tt-best">1:15.234</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot pb"></div><div class="s-dot nor"></div><div class="s-dot nor"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">5</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-mer)"></div><span class="drv-code">RUS</span><span class="drv-num">63</span></div></td>
<td class="tt-gap">+18.234</td>
<td class="tt-int">+5.667</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age">14</td>
<td class="tt-last">1:15.456</td>
<td class="tt-best">1:15.100</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot pb"></div><div class="s-dot pb"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">6</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-fer)"></div><span class="drv-code">HAM</span><span class="drv-num">44</span></div></td>
<td class="tt-gap">+24.567</td>
<td class="tt-int">+6.333</td>
<td style="text-align:center"><span class="tyre M">M</span></td>
<td class="tt-age">8</td>
<td class="tt-last">1:16.012</td>
<td class="tt-best">1:15.567</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot nor"></div><div class="s-dot pb"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">7</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-am)"></div><span class="drv-code">ALO</span><span class="drv-num">14</span></div></td>
<td class="tt-gap">+31.234</td>
<td class="tt-int">+6.667</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age">43</td>
<td class="tt-last">1:16.234</td>
<td class="tt-best">1:15.890</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot nor"></div><div class="s-dot nor"></div></div></td>
</tr>
<tr class="pit">
<td class="left tt-pos" style="padding-left:var(--s4)">8</td>
<td class="left">
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-wil)"></div>
<span class="drv-code">SAI</span>
<span class="drv-num">55</span>
<span style="font-size:9px;color:var(--c-yellow);font-weight:700;margin-left:2px">PIT OUT</span>
</div>
</td>
<td class="tt-gap" style="color:var(--c-yellow)">+38.901</td>
<td class="tt-int">+7.667</td>
<td style="text-align:center"><span class="tyre S">S</span></td>
<td class="tt-age">2</td>
<td class="tt-last">1:41.678</td>
<td class="tt-best">1:15.456</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot nor"></div><div class="s-dot nor"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">9</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-alp)"></div><span class="drv-code t3">GAS</span><span class="drv-num">10</span></div></td>
<td class="tt-gap t3">+45.234</td>
<td class="tt-int t3">+6.333</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age t3">37</td>
<td class="tt-last t3">1:16.890</td>
<td class="tt-best t3">1:16.234</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot nor"></div><div class="s-dot nor"></div></div></td>
</tr>
<tr>
<td class="left tt-pos" style="padding-left:var(--s4)">10</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-haas)"></div><span class="drv-code t3">OCO</span><span class="drv-num">31</span></div></td>
<td class="tt-gap t3">+52.567</td>
<td class="tt-int t3">+7.333</td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age t3">23</td>
<td class="tt-last t3">1:17.012</td>
<td class="tt-best t3">1:16.567</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot nor"></div><div class="s-dot nor"></div></div></td>
</tr>
<tr style="opacity:0.45">
<td class="left" style="padding-left:var(--s4);font-family:var(--f-mono);font-size:12px;color:var(--c-text-3);text-align:center">11</td>
<td class="left"><div class="drv-cell"><div class="drv-bar" style="background:var(--t-vcarb)"></div><span class="drv-code t3">TSU</span><span class="drv-num">22</span></div></td>
<td class="tt-gap t3 mono">+1 LAP</td>
<td class="tt-int t3"></td>
<td style="text-align:center"><span class="tyre H">H</span></td>
<td class="tt-age t3">45</td>
<td class="tt-last t3">1:17.456</td>
<td class="tt-best t3">1:17.012</td>
<td class="hide-mobile left"><div class="sectors"><div class="s-dot nor"></div><div class="s-dot nor"></div><div class="s-dot nor"></div></div></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ── Right Sidebar ── -->
<div class="lt-sidebar">
<!-- Race Control -->
<div class="sb-panel" style="flex:1.8">
<div class="sb-head">
<span class="sb-title">Race Control</span>
<span class="dot live" style="margin-left:auto"></span>
</div>
<div class="sb-body">
<div class="rc-side-msg drs">
<span class="rc-side-lap">L27</span>
<span class="rc-side-text">DRS ENABLED — Lap 27</span>
</div>
<div class="rc-side-msg sc">
<span class="rc-side-lap">L26</span>
<span class="rc-side-text">SAFETY CAR IN THIS LAP</span>
</div>
<div class="rc-side-msg sc">
<span class="rc-side-lap">L23</span>
<span class="rc-side-text">SC DEPLOYED — ALB retirement T10</span>
</div>
<div class="rc-side-msg">
<span class="rc-side-lap">L35</span>
<span class="rc-side-text">5s PENALTY — RUS · Unsafe release</span>
</div>
<div class="rc-side-msg fl">
<span class="rc-side-lap">L38</span>
<span class="rc-side-text">FASTEST LAP — NOR 1:14.756</span>
</div>
<div class="rc-side-msg">
<span class="rc-side-lap">L3</span>
<span class="rc-side-text">DRS ENABLED — Lap 3</span>
</div>
<div class="rc-side-msg">
<span class="rc-side-lap">L1</span>
<span class="rc-side-text">RACE START — Track Clear</span>
</div>
</div>
</div>
<!-- Battles -->
<div class="sb-panel" style="flex:1">
<div class="sb-head">
<span class="sb-title">Battles</span>
</div>
<div class="sb-body">
<div class="battle-row">
<div class="battle-drivers">
<span class="drv-code" style="color:var(--t-mcl)">NOR</span>
<span class="t3" style="font-size:10px">vs</span>
<span class="drv-code" style="color:var(--t-mcl);opacity:.7">PIA</span>
</div>
<span class="battle-gap">+5.3s</span>
<span class="battle-trend t-green"></span>
</div>
<div class="battle-row">
<div class="battle-drivers">
<span class="drv-code" style="color:var(--t-fer)">HAM</span>
<span class="t3" style="font-size:10px">vs</span>
<span class="drv-code" style="color:var(--t-am)">ALO</span>
</div>
<span class="battle-gap">+6.3s</span>
<span class="battle-trend t3"></span>
</div>
<div class="battle-row">
<div class="battle-drivers">
<span class="drv-code" style="color:var(--t-wil)">SAI</span>
<span class="t3" style="font-size:10px">vs</span>
<span class="drv-code" style="color:var(--t-alp)">GAS</span>
</div>
<span class="battle-gap t-orange">+6.4s</span>
<span class="battle-trend t-red"></span>
</div>
</div>
</div>
<!-- Pit Window -->
<div class="sb-panel" style="flex:1.2">
<div class="sb-head">
<span class="sb-title">Pit Window</span>
<span class="t3" style="font-size:10px;margin-left:var(--s2)">L45</span>
</div>
<div class="sb-body">
<div class="pit-row">
<div class="drv-cell" style="gap:5px;flex:1">
<div class="drv-bar" style="background:var(--t-fer);height:14px"></div>
<span class="drv-code" style="font-size:12px">LEC</span>
</div>
<span class="tyre M" style="width:16px;height:16px;font-size:8px;margin:0 4px">M</span>
<span class="pit-tyre-age">L12</span>
<span class="pit-status soon">SOON</span>
</div>
<div class="pit-row">
<div class="drv-cell" style="gap:5px;flex:1">
<div class="drv-bar" style="background:var(--t-rb);height:14px"></div>
<span class="drv-code" style="font-size:12px">VER</span>
</div>
<span class="tyre H" style="width:16px;height:16px;font-size:8px;margin:0 4px">H</span>
<span class="pit-tyre-age">L8</span>
<span class="pit-status open">OPEN</span>
</div>
<div class="pit-row">
<div class="drv-cell" style="gap:5px;flex:1">
<div class="drv-bar" style="background:var(--t-mcl);height:14px"></div>
<span class="drv-code" style="font-size:12px">NOR</span>
</div>
<span class="tyre H" style="width:16px;height:16px;font-size:8px;margin:0 4px">H</span>
<span class="pit-tyre-age">L28</span>
<span class="pit-status overdue">OVERDUE</span>
</div>
<div class="pit-row">
<div class="drv-cell" style="gap:5px;flex:1">
<div class="drv-bar" style="background:var(--t-mer);height:14px"></div>
<span class="drv-code" style="font-size:12px">RUS</span>
</div>
<span class="tyre H" style="width:16px;height:16px;font-size:8px;margin:0 4px">H</span>
<span class="pit-tyre-age">L14</span>
<span class="pit-status open">OPEN</span>
</div>
<div class="pit-row">
<div class="drv-cell" style="gap:5px;flex:1">
<div class="drv-bar" style="background:var(--t-wil);height:14px"></div>
<span class="drv-code" style="font-size:12px">SAI</span>
</div>
<span class="tyre S" style="width:16px;height:16px;font-size:8px;margin:0 4px">S</span>
<span class="pit-tyre-age">L2</span>
<span class="pit-status done">DONE</span>
</div>
</div>
</div>
</div><!-- /lt-sidebar -->
</div><!-- /lt-body -->
<!-- ── Footer ── -->
<div class="lt-footer">
<div class="fl-item">
<span class="fl-label">Fastest Lap</span>
<div class="drv-cell" style="gap:5px">
<div class="drv-bar" style="background:var(--t-mcl);height:14px"></div>
<span style="font-weight:700;color:var(--c-purple)">NOR</span>
</div>
<span class="mono" style="color:var(--c-purple)">1:14.756</span>
<span class="t3 mono" style="font-size:11px">L38</span>
</div>
<div style="margin-left:auto;display:flex;align-items:center;gap:var(--s4)">
<div class="fl-item">
<span class="fl-label">S1</span>
<span class="mono t-purple">NOR 19.234</span>
</div>
<div class="fl-item">
<span class="fl-label">S2</span>
<span class="mono t-purple">LEC 32.456</span>
</div>
<div class="fl-item">
<span class="fl-label">S3</span>
<span class="mono t-purple">NOR 23.066</span>
</div>
<div class="fl-item" style="margin-left:var(--s3)">
<span class="fl-label">Lead Change</span>
<span class="t2 mono">L2 LEC overtook VER</span>
</div>
</div>
</div>
<!-- ── Phone panels (desktop: hidden via CSS above) ── -->
<div class="phone-panel active" id="phone-panel-tower" style="padding:0;display:none"></div>
<div class="phone-panel" id="phone-panel-rc" style="display:none">
<div style="font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--c-text-3);margin-bottom:var(--s3)">Race Control</div>
<div class="rc-side-msg drs"><span class="rc-side-lap">L27</span><span class="rc-side-text">DRS ENABLED — Lap 27</span></div>
<div class="rc-side-msg sc"><span class="rc-side-lap">L26</span><span class="rc-side-text">SAFETY CAR IN THIS LAP</span></div>
<div class="rc-side-msg sc"><span class="rc-side-lap">L23</span><span class="rc-side-text">SC DEPLOYED — ALB retirement T10</span></div>
<div class="rc-side-msg"><span class="rc-side-lap">L35</span><span class="rc-side-text">5s PENALTY — RUS · Unsafe release</span></div>
<div class="rc-side-msg fl"><span class="rc-side-lap">L38</span><span class="rc-side-text">FASTEST LAP — NOR 1:14.756</span></div>
</div>
<div class="phone-panel" id="phone-panel-battles" style="display:none">
<div style="font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--c-text-3);margin-bottom:var(--s3)">Battles</div>
<div class="battle-row"><div class="battle-drivers"><span class="drv-code" style="color:var(--t-mcl)">NOR</span><span class="t3" style="font-size:10px">vs</span><span class="drv-code" style="color:var(--t-mcl);opacity:.7">PIA</span></div><span class="battle-gap">+5.3s</span><span class="battle-trend t-green">▼ closing</span></div>
<div class="battle-row"><div class="battle-drivers"><span class="drv-code" style="color:var(--t-fer)">HAM</span><span class="t3" style="font-size:10px">vs</span><span class="drv-code" style="color:var(--t-am)">ALO</span></div><span class="battle-gap">+6.3s</span><span class="battle-trend t3">— stable</span></div>
<div class="battle-row"><div class="battle-drivers"><span class="drv-code" style="color:var(--t-wil)">SAI</span><span class="t3" style="font-size:10px">vs</span><span class="drv-code" style="color:var(--t-alp)">GAS</span></div><span class="battle-gap t-orange">+6.4s</span><span class="battle-trend t-red">▲ SAI catching</span></div>
<div style="margin-top:var(--s4);font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--c-text-3);margin-bottom:var(--s3)">Pit Window</div>
<div class="pit-row"><div class="drv-cell" style="gap:5px;flex:1"><div class="drv-bar" style="background:var(--t-fer);height:14px"></div><span class="drv-code" style="font-size:12px">LEC</span></div><span class="tyre M" style="width:16px;height:16px;font-size:8px;margin:0 4px">M</span><span class="pit-tyre-age">L12</span><span class="pit-status soon">SOON</span></div>
<div class="pit-row"><div class="drv-cell" style="gap:5px;flex:1"><div class="drv-bar" style="background:var(--t-mcl);height:14px"></div><span class="drv-code" style="font-size:12px">NOR</span></div><span class="tyre H" style="width:16px;height:16px;font-size:8px;margin:0 4px">H</span><span class="pit-tyre-age">L28</span><span class="pit-status overdue">OVERDUE</span></div>
</div>
<!-- ── Phone bottom tabs ── -->
<nav class="phone-tab-bar">
<button class="active" onclick="phoneTab('tower',this)">
<span class="tab-icon"></span>Tower
</button>
<button onclick="phoneTab('rc',this)">
<span class="tab-icon">📡</span>RC
</button>
<button onclick="phoneTab('battles',this)">
<span class="tab-icon"></span>Battles
</button>
</nav>
</div><!-- /lt-root -->
<script>
function setDensity(mode, btn) {
document.querySelectorAll('.density-toggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.documentElement.classList.toggle('compact', mode === 'compact');
}
function phoneTab(panel, btn) {
if (window.innerWidth > 768) return;
document.querySelectorAll('.phone-tab-bar button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Show/hide timing tower
const tower = document.getElementById('panel-tower');
tower.style.display = panel === 'tower' ? '' : 'none';
// Show/hide phone panels
document.querySelectorAll('.phone-panel').forEach(p => p.style.display = 'none');
if (panel !== 'tower') {
const phonePanel = document.getElementById('phone-panel-' + panel);
if (phonePanel) phonePanel.style.display = 'block';
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,939 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>box-box · Live Timing — Phone Layout</title>
<link rel="stylesheet" href="styles.css">
<style>
/* ─── Phone Live Layout ───────────────────────────────────────────────── */
/* This file demonstrates the phone layout model in isolation.
The real responsive live-timing.html collapses to this at <768px.
Reading this file answers: "What exactly does the phone experience look like?" */
body {
margin: 0;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font-mono);
font-size: 13px;
}
/* Simulate phone chrome — constrain to 390px centered */
.phone-frame {
max-width: 390px;
margin: 0 auto;
min-height: 100vh;
position: relative;
display: flex;
flex-direction: column;
background: var(--c-bg);
border-left: 1px solid var(--c-border-2);
border-right: 1px solid var(--c-border-2);
}
/* Demo note outside phone frame */
.demo-meta {
text-align: center;
padding: 12px 16px;
font-family: var(--font-sans);
font-size: 11px;
color: var(--c-text-3);
letter-spacing: 0.04em;
border-bottom: 1px solid var(--c-border);
}
.demo-meta a { color: var(--c-text-2); text-decoration: none; }
.demo-meta a:hover { color: var(--c-text); }
/* ─── Session Banner (phone variant) ─────────────────────────────────── */
/* Compact — session name + lap + track status only.
Other fields (temps, DRS, fastest lap) are not shown on phone. */
.sb {
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 0 12px;
height: 46px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 40;
}
.sb-live-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--c-red);
flex-shrink: 0;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.sb-session {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.sb-lap {
font-size: 12px;
color: var(--c-text-2);
white-space: nowrap;
flex-shrink: 0;
}
.sb-lap span { color: var(--c-text); font-weight: 600; }
.sb-track-status {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 2px;
flex-shrink: 0;
}
.sb-track-status.green { background: rgba(0,204,106,0.15); color: var(--c-green); }
.sb-track-status.yellow { background: rgba(255,214,0,0.15); color: var(--c-yellow); }
.sb-track-status.sc { background: rgba(255,214,0,0.2); color: var(--c-yellow); }
.sb-track-status.red { background: rgba(225,6,0,0.15); color: var(--c-red); }
.sb-disconnected {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 2px;
background: rgba(225,6,0,0.2);
color: var(--c-red);
animation: blink 1s step-end infinite;
flex-shrink: 0;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ─── Panel Container ─────────────────────────────────────────────────── */
/* Each panel occupies the full space between banner and tab bar.
Only one panel is visible at a time. */
.panel-area {
flex: 1;
overflow: hidden;
position: relative;
/* bottom padding = tab bar height so content is not obscured */
padding-bottom: 54px;
}
.phone-panel {
display: none;
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.phone-panel.active { display: block; }
/* ─── PANEL: Tower ────────────────────────────────────────────────────── */
/* Phone columns: P · Driver · Gap · Tyre · Last
Hidden: Interval, Age, Best, Sectors */
.tower-table {
width: 100%;
border-collapse: collapse;
}
.tower-table thead th {
position: sticky;
top: 0;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
padding: 5px 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--c-text-3);
text-align: right;
white-space: nowrap;
z-index: 10;
}
.tower-table thead th.col-driver { text-align: left; }
.tower-table tbody tr {
border-bottom: 1px solid var(--c-border);
height: 38px;
}
.tower-table tbody tr:hover { background: var(--c-surface); }
.tower-table tbody tr.pinned { background: rgba(255,255,255,0.03); }
.tower-table td {
padding: 0 8px;
text-align: right;
vertical-align: middle;
white-space: nowrap;
}
.td-pos {
width: 26px;
font-size: 13px;
font-weight: 700;
color: var(--c-text);
text-align: center;
}
.td-driver {
text-align: left;
min-width: 0;
}
.driver-cell {
display: flex;
align-items: center;
gap: 7px;
}
.team-bar {
width: 3px;
height: 22px;
border-radius: 1px;
flex-shrink: 0;
}
.driver-code {
font-weight: 700;
font-size: 13px;
color: var(--c-text);
letter-spacing: 0.02em;
}
.td-gap { font-size: 12px; color: var(--c-text-2); min-width: 58px; }
.td-gap.leader { color: var(--c-text); font-weight: 600; font-size: 10px; letter-spacing: 0.05em; }
.td-tyre {
width: 32px;
}
.tyre-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
}
.tyre-badge.S { background: rgba(232,0,45,0.18); color: var(--tyre-s); }
.tyre-badge.M { background: rgba(200,180,0,0.18); color: var(--tyre-m); }
.tyre-badge.H { background: rgba(184,184,184,0.15); color: var(--tyre-h); }
.td-last { font-size: 12px; color: var(--c-text); min-width: 58px; }
.td-last.pb { color: var(--c-green); }
.td-last.ob { color: var(--c-purple); }
/* Special states */
.row-pit td { opacity: 0.55; }
.row-pit .driver-code::after {
content: " PIT";
font-size: 9px;
font-weight: 600;
color: var(--c-yellow);
letter-spacing: 0.08em;
vertical-align: middle;
margin-left: 2px;
}
.row-fl td.td-last { color: var(--c-purple); }
.row-dnf td { opacity: 0.35; text-decoration: line-through; text-decoration-color: var(--c-border-2); }
/* ─── PANEL: Race Control ─────────────────────────────────────────────── */
.rc-panel { padding: 0; }
.rc-panel-header {
padding: 10px 12px 8px;
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--c-text-3);
text-transform: uppercase;
border-bottom: 1px solid var(--c-border);
position: sticky;
top: 0;
background: var(--c-bg);
z-index: 10;
}
.rc-list { list-style: none; margin: 0; padding: 0; }
.rc-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 9px 12px;
border-bottom: 1px solid var(--c-border);
font-size: 12px;
line-height: 1.45;
}
.rc-item:last-child { border-bottom: none; }
.rc-lap {
font-size: 10px;
color: var(--c-text-3);
white-space: nowrap;
padding-top: 1px;
flex-shrink: 0;
min-width: 32px;
}
.rc-dot {
width: 8px; height: 8px;
border-radius: 50%;
margin-top: 3px;
flex-shrink: 0;
}
.rc-text { color: var(--c-text-2); flex: 1; }
.rc-text strong { color: var(--c-text); }
.rc-item.sc .rc-dot { background: var(--c-yellow); }
.rc-item.sc .rc-text { color: var(--c-yellow); }
.rc-item.vsc .rc-dot { background: var(--c-yellow); }
.rc-item.vsc .rc-text { color: var(--c-yellow); }
.rc-item.drs .rc-dot { background: var(--c-green); }
.rc-item.penalty .rc-dot { background: var(--c-red); }
.rc-item.fl .rc-dot { background: var(--c-purple); }
.rc-item.fl .rc-text { color: var(--c-purple); }
.rc-item.flag .rc-dot { background: var(--c-red); }
.rc-item.flag .rc-text { color: var(--c-red); }
.rc-item.info .rc-dot { background: var(--c-text-3); }
/* ─── PANEL: Battles + Pit ─────────────────────────────────────────────── */
.battles-section {
padding: 10px 12px 0;
}
.section-label {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--c-text-3);
text-transform: uppercase;
margin-bottom: 8px;
}
.battle-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--c-border);
font-size: 12px;
}
.battle-row:last-child { border-bottom: none; }
.battle-driver {
display: flex;
align-items: center;
gap: 5px;
width: 52px;
}
.bd-bar {
width: 3px; height: 18px;
border-radius: 1px;
flex-shrink: 0;
}
.bd-code {
font-weight: 700;
font-size: 12px;
color: var(--c-text);
letter-spacing: 0.02em;
}
.battle-vs { color: var(--c-text-3); font-size: 10px; }
.battle-gap { color: var(--c-text); font-weight: 600; font-size: 13px; min-width: 50px; text-align: right; }
.battle-trend { margin-left: auto; font-size: 12px; }
.battle-trend.closing { color: var(--c-red); }
.battle-trend.stable { color: var(--c-text-3); }
.battle-trend.opening { color: var(--c-text-3); }
.pit-section {
padding: 10px 12px 0;
margin-top: 4px;
border-top: 1px solid var(--c-border);
}
.pit-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--c-border);
font-size: 12px;
}
.pit-row:last-child { border-bottom: none; }
.pit-driver {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
}
.pit-tyre-age { color: var(--c-text-3); font-size: 11px; margin-left: 2px; }
.pit-status-badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
padding: 2px 6px;
border-radius: 2px;
flex-shrink: 0;
}
.pit-status-badge.open { background: rgba(0,204,106,0.12); color: var(--c-green); }
.pit-status-badge.soon { background: rgba(255,214,0,0.12); color: var(--c-yellow); }
.pit-status-badge.overdue { background: rgba(255,140,0,0.12); color: #ff8c00; }
.pit-status-badge.done { background: rgba(255,255,255,0.04); color: var(--c-text-3); }
/* ─── Bottom Tab Bar ─────────────────────────────────────────────────── */
.tab-bar {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 390px;
height: 54px;
background: var(--c-surface);
border-top: 1px solid var(--c-border-2);
display: flex;
align-items: stretch;
z-index: 50;
}
.tab-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--c-text-3);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
transition: color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.tab-btn .tab-icon {
font-size: 16px;
line-height: 1;
}
.tab-btn.active {
color: var(--c-text);
}
.tab-btn.active .tab-icon { color: var(--c-red); }
.tab-btn:hover { color: var(--c-text-2); }
/* RC badge — unread count */
.tab-badge {
position: relative;
}
.tab-badge::after {
content: "3";
position: absolute;
top: -4px;
right: -8px;
background: var(--c-red);
color: white;
font-size: 9px;
font-weight: 700;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* ─── Disconnected banner (full-panel overlay) ────────────────────────── */
/* This is what happens when SSE drops. Not just the status in the banner —
a real-estate banner below the session bar. */
.disconnect-banner {
display: none; /* toggle .show to show */
background: rgba(225,6,0,0.12);
border-bottom: 1px solid rgba(225,6,0,0.3);
padding: 8px 12px;
font-family: var(--font-sans);
font-size: 12px;
color: var(--c-red);
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.disconnect-banner.show { display: flex; }
.disconnect-banner svg { flex-shrink: 0; }
.reconnect-btn {
margin-left: auto;
background: rgba(225,6,0,0.15);
border: 1px solid rgba(225,6,0,0.35);
color: var(--c-red);
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
font-family: var(--font-sans);
white-space: nowrap;
}
</style>
</head>
<body>
<div class="demo-meta">
Mobile Live Layout Demo · 390px phone viewport ·
<a href="live-timing.html">← full responsive version</a> ·
<a href="index.html">index</a>
</div>
<!-- ─── Phone Frame ──────────────────────────────────────────────────────── -->
<div class="phone-frame">
<!-- Session Banner (phone variant: session name + lap + track status) -->
<div class="sb">
<div class="sb-live-dot"></div>
<div class="sb-session">Monaco GP — Race</div>
<div class="sb-lap">Lap <span>47</span>/78</div>
<div class="sb-track-status sc">SC</div>
</div>
<!-- Disconnected state banner (hidden by default — uncomment class to preview) -->
<div class="disconnect-banner">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1L13 12H1L7 1Z" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M7 5.5V8.5M7 10V10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
SSE disconnected — data may be stale
<button class="reconnect-btn">Reconnect</button>
</div>
<!-- Panel area — only one .phone-panel.active at a time -->
<div class="panel-area">
<!-- PANEL: Timing Tower (default active) -->
<div id="panel-tower" class="phone-panel active">
<table class="tower-table">
<thead>
<tr>
<th style="width:26px; text-align:center;">P</th>
<th class="col-driver" style="min-width:90px;">Driver</th>
<th style="min-width:58px;">Gap</th>
<th style="width:36px;">Tyre</th>
<th style="min-width:58px;">Last</th>
</tr>
</thead>
<tbody>
<!-- P1 - Leclerc -->
<tr class="row-fl">
<td class="td-pos">1</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-fer);"></div>
<span class="driver-code">LEC</span>
</div>
</td>
<td class="td-gap leader">LEADER</td>
<td class="td-tyre"><span class="tyre-badge M">M</span></td>
<td class="td-last ob">1:14.812</td>
</tr>
<!-- P2 - Verstappen -->
<tr>
<td class="td-pos">2</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-rb);"></div>
<span class="driver-code">VER</span>
</div>
</td>
<td class="td-gap">+3.456</td>
<td class="td-tyre"><span class="tyre-badge M">M</span></td>
<td class="td-last">1:15.203</td>
</tr>
<!-- P3 - Norris (in pits during SC) -->
<tr class="row-pit">
<td class="td-pos">3</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-mcl);"></div>
<span class="driver-code">NOR</span>
</div>
</td>
<td class="td-gap">+8.102</td>
<td class="td-tyre"><span class="tyre-badge S">S</span></td>
<td class="td-last">1:16.044</td>
</tr>
<!-- P4 - Hamilton -->
<tr>
<td class="td-pos">4</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-fer);"></div>
<span class="driver-code">HAM</span>
</div>
</td>
<td class="td-gap">+11.723</td>
<td class="td-tyre"><span class="tyre-badge H">H</span></td>
<td class="td-last pb">1:15.991</td>
</tr>
<!-- P5 - Russell -->
<tr>
<td class="td-pos">5</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-mer);"></div>
<span class="driver-code">RUS</span>
</div>
</td>
<td class="td-gap">+14.088</td>
<td class="td-tyre"><span class="tyre-badge M">M</span></td>
<td class="td-last">1:16.388</td>
</tr>
<!-- P6 - Piastri -->
<tr>
<td class="td-pos">6</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-mcl);"></div>
<span class="driver-code">PIA</span>
</div>
</td>
<td class="td-gap">+18.430</td>
<td class="td-tyre"><span class="tyre-badge S">S</span></td>
<td class="td-last">1:16.701</td>
</tr>
<!-- P7 - Sainz -->
<tr>
<td class="td-pos">7</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: rgba(255,255,255,0.25);"></div>
<span class="driver-code">SAI</span>
</div>
</td>
<td class="td-gap">+22.115</td>
<td class="td-tyre"><span class="tyre-badge H">H</span></td>
<td class="td-last">1:16.924</td>
</tr>
<!-- P8 - Antonelli -->
<tr>
<td class="td-pos">8</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: var(--t-mer);"></div>
<span class="driver-code">ANT</span>
</div>
</td>
<td class="td-gap">+27.660</td>
<td class="td-tyre"><span class="tyre-badge M">M</span></td>
<td class="td-last">1:17.204</td>
</tr>
<!-- P9 - Alonso -->
<tr>
<td class="td-pos">9</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: rgba(0,143,93,0.9);"></div>
<span class="driver-code">ALO</span>
</div>
</td>
<td class="td-gap">+35.291</td>
<td class="td-tyre"><span class="tyre-badge H">H</span></td>
<td class="td-last">1:17.450</td>
</tr>
<!-- P10 - Gasly -->
<tr>
<td class="td-pos">10</td>
<td class="td-driver">
<div class="driver-cell">
<div class="team-bar" style="background: rgba(0,144,209,0.85);"></div>
<span class="driver-code">GAS</span>
</div>
</td>
<td class="td-gap">+42.880</td>
<td class="td-tyre"><span class="tyre-badge M">M</span></td>
<td class="td-last">1:17.692</td>
</tr>
<!-- P11-P15 (midfield) -->
<tr>
<td class="td-pos">11</td>
<td class="td-driver"><div class="driver-cell"><div class="team-bar" style="background: rgba(100,192,84,0.85);"></div><span class="driver-code">OCO</span></div></td>
<td class="td-gap">+51.334</td>
<td class="td-tyre"><span class="tyre-badge H">H</span></td>
<td class="td-last">1:18.103</td>
</tr>
<tr>
<td class="td-pos">12</td>
<td class="td-driver"><div class="driver-cell"><div class="team-bar" style="background: rgba(100,192,84,0.85);"></div><span class="driver-code">HUL</span></div></td>
<td class="td-gap">+55.771</td>
<td class="td-tyre"><span class="tyre-badge S">S</span></td>
<td class="td-last">1:18.320</td>
</tr>
<tr>
<td class="td-pos">13</td>
<td class="td-driver"><div class="driver-cell"><div class="team-bar" style="background: rgba(90,90,100,0.7);"></div><span class="driver-code">STR</span></div></td>
<td class="td-gap">+1:04.2</td>
<td class="td-tyre"><span class="tyre-badge M">M</span></td>
<td class="td-last">1:18.890</td>
</tr>
<tr>
<td class="td-pos">14</td>
<td class="td-driver"><div class="driver-cell"><div class="team-bar" style="background: rgba(200,175,60,0.75);"></div><span class="driver-code">TSU</span></div></td>
<td class="td-gap">+1 LAP</td>
<td class="td-tyre"><span class="tyre-badge H">H</span></td>
<td class="td-last">1:19.210</td>
</tr>
<!-- P15 — DNF -->
<tr class="row-dnf">
<td class="td-pos" style="color: var(--c-text-3);">15</td>
<td class="td-driver"><div class="driver-cell"><div class="team-bar" style="background: rgba(255,255,255,0.12);"></div><span class="driver-code">ZHO</span></div></td>
<td class="td-gap" style="color: var(--c-text-3);">DNF</td>
<td class="td-tyre"><span class="tyre-badge S" style="opacity:0.4;">S</span></td>
<td class="td-last" style="color: var(--c-text-3);"></td>
</tr>
</tbody>
</table>
</div>
<!-- PANEL: Race Control -->
<div id="panel-rc" class="phone-panel">
<div class="rc-panel-header">Race Control</div>
<ul class="rc-list">
<li class="rc-item sc">
<span class="rc-lap">L47</span>
<div class="rc-dot"></div>
<span class="rc-text">SAFETY CAR DEPLOYED — Incident at Turn 10 (Bottas, Albon)</span>
</li>
<li class="rc-item sc">
<span class="rc-lap">L47</span>
<div class="rc-dot"></div>
<span class="rc-text">PIT LANE OPEN during Safety Car period</span>
</li>
<li class="rc-item penalty">
<span class="rc-lap">L44</span>
<div class="rc-dot"></div>
<span class="rc-text"><strong>VER</strong> — 5 SECOND PENALTY · Causing a collision with NOR at T1</span>
</li>
<li class="rc-item drs">
<span class="rc-lap">L42</span>
<div class="rc-dot"></div>
<span class="rc-text">DRS ENABLED — Zones 1, 2 and 3</span>
</li>
<li class="rc-item fl">
<span class="rc-lap">L41</span>
<div class="rc-dot"></div>
<span class="rc-text"><strong>LEC</strong> — FASTEST LAP · 1:12.456 on Lap 41</span>
</li>
<li class="rc-item flag">
<span class="rc-lap">L38</span>
<div class="rc-dot"></div>
<span class="rc-text">RED FLAG — Track debris at Casino corner, extraction in progress</span>
</li>
<li class="rc-item info">
<span class="rc-lap">L38</span>
<div class="rc-dot"></div>
<span class="rc-text">RACE SUSPENDED · All cars to proceed to pit lane</span>
</li>
<li class="rc-item info">
<span class="rc-lap">L36</span>
<div class="rc-dot"></div>
<span class="rc-text">Stewards investigating incident between ALO and STR — Turn 6</span>
</li>
<li class="rc-item vsc">
<span class="rc-lap">L31</span>
<div class="rc-dot"></div>
<span class="rc-text">VIRTUAL SAFETY CAR DEPLOYED — Bottas car recovered to pit lane</span>
</li>
<li class="rc-item vsc">
<span class="rc-lap">L33</span>
<div class="rc-dot"></div>
<span class="rc-text">VIRTUAL SAFETY CAR ENDING — Racing to resume next lap</span>
</li>
<li class="rc-item drs">
<span class="rc-lap">L34</span>
<div class="rc-dot"></div>
<span class="rc-text">DRS ENABLED — Zones 1, 2 and 3</span>
</li>
<li class="rc-item info">
<span class="rc-lap">L28</span>
<div class="rc-dot"></div>
<span class="rc-text">Weather: Track Temp 44°C / Air Temp 28°C / Humidity 62%</span>
</li>
</ul>
</div>
<!-- PANEL: Battles + Pit Window -->
<div id="panel-battles" class="phone-panel">
<div class="battles-section">
<div class="section-label">On-Track Battles</div>
<div class="battle-row">
<div class="battle-driver">
<div class="bd-bar" style="background: var(--t-fer);"></div>
<span class="bd-code">LEC</span>
</div>
<span class="battle-vs">vs</span>
<div class="battle-driver">
<div class="bd-bar" style="background: var(--t-rb);"></div>
<span class="bd-code">VER</span>
</div>
<span class="battle-gap">3.456s</span>
<span class="battle-trend closing">↓ closing</span>
</div>
<div class="battle-row">
<div class="battle-driver">
<div class="bd-bar" style="background: var(--t-fer);"></div>
<span class="bd-code">HAM</span>
</div>
<span class="battle-vs">vs</span>
<div class="battle-driver">
<div class="bd-bar" style="background: var(--t-mer);"></div>
<span class="bd-code">RUS</span>
</div>
<span class="battle-gap">2.365s</span>
<span class="battle-trend stable">— stable</span>
</div>
<div class="battle-row">
<div class="battle-driver">
<div class="bd-bar" style="background: var(--t-mcl);"></div>
<span class="bd-code">PIA</span>
</div>
<span class="battle-vs">vs</span>
<div class="battle-driver">
<div class="bd-bar" style="background: rgba(255,255,255,0.25);"></div>
<span class="bd-code">SAI</span>
</div>
<span class="battle-gap">4.315s</span>
<span class="battle-trend closing">↓ closing</span>
</div>
<div class="battle-row">
<div class="battle-driver">
<div class="bd-bar" style="background: rgba(0,143,93,0.9);"></div>
<span class="bd-code">ALO</span>
</div>
<span class="battle-vs">vs</span>
<div class="battle-driver">
<div class="bd-bar" style="background: rgba(0,144,209,0.85);"></div>
<span class="bd-code">GAS</span>
</div>
<span class="battle-gap">6.411s</span>
<span class="battle-trend opening">↑ opening</span>
</div>
</div>
<!-- Pit Window below battles in same panel -->
<div class="pit-section">
<div class="section-label">Pit Window</div>
<div class="pit-row">
<div class="pit-driver">
<div class="bd-bar" style="background: var(--t-rb);"></div>
<span class="bd-code">VER</span>
<span class="tyre-badge M" style="width:18px;height:18px;font-size:10px;">M</span>
<span class="pit-tyre-age">+24</span>
</div>
<span class="pit-status-badge overdue">OVERDUE</span>
</div>
<div class="pit-row">
<div class="pit-driver">
<div class="bd-bar" style="background: var(--t-fer);"></div>
<span class="bd-code">HAM</span>
<span class="tyre-badge H" style="width:18px;height:18px;font-size:10px;">H</span>
<span class="pit-tyre-age">+31</span>
</div>
<span class="pit-status-badge soon">SOON</span>
</div>
<div class="pit-row">
<div class="pit-driver">
<div class="bd-bar" style="background: var(--t-mer);"></div>
<span class="bd-code">RUS</span>
<span class="tyre-badge M" style="width:18px;height:18px;font-size:10px;">M</span>
<span class="pit-tyre-age">+19</span>
</div>
<span class="pit-status-badge open">OPEN</span>
</div>
<div class="pit-row">
<div class="pit-driver">
<div class="bd-bar" style="background: rgba(0,143,93,0.9);"></div>
<span class="bd-code">ALO</span>
<span class="tyre-badge H" style="width:18px;height:18px;font-size:10px;">H</span>
<span class="pit-tyre-age">+38</span>
</div>
<span class="pit-status-badge overdue">OVERDUE</span>
</div>
<div class="pit-row">
<div class="pit-driver">
<div class="bd-bar" style="background: var(--t-fer);"></div>
<span class="bd-code">LEC</span>
<span class="tyre-badge M" style="width:18px;height:18px;font-size:10px;">M</span>
<span class="pit-tyre-age">+12</span>
</div>
<span class="pit-status-badge done">DONE</span>
</div>
<div class="pit-row">
<div class="pit-driver">
<div class="bd-bar" style="background: var(--t-mcl);"></div>
<span class="bd-code">NOR</span>
<span class="tyre-badge S" style="width:18px;height:18px;font-size:10px;">S</span>
<span class="pit-tyre-age">+1</span>
</div>
<span class="pit-status-badge done">DONE</span>
</div>
</div>
</div>
</div><!-- /panel-area -->
<!-- Bottom Tab Bar (always visible, fixed) -->
<div class="tab-bar">
<button class="tab-btn active" id="tab-tower" onclick="switchTab('tower', this)">
<span class="tab-icon"></span>
Tower
</button>
<button class="tab-btn" id="tab-rc" onclick="switchTab('rc', this)">
<span class="tab-icon tab-badge">📻</span>
Race Ctrl
</button>
<button class="tab-btn" id="tab-battles" onclick="switchTab('battles', this)">
<span class="tab-icon"></span>
Battles
</button>
</div>
</div><!-- /phone-frame -->
<script>
function switchTab(panel, btn) {
// Update tab button states
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Show/hide panels
document.querySelectorAll('.phone-panel').forEach(p => p.classList.remove('active'));
document.getElementById('panel-' + panel).classList.add('active');
// Clear RC badge on visit
if (panel === 'rc') {
const badge = document.querySelector('.tab-badge');
if (badge) badge.style.setProperty('--badge-content', '""');
// In React: mark messages as read via callback
}
}
// Demo: toggle the disconnect state by pressing 'd'
document.addEventListener('keydown', e => {
if (e.key === 'd') {
const banner = document.querySelector('.disconnect-banner');
banner.classList.toggle('show');
}
if (e.key === 's') {
// Cycle track status for demo
const s = document.querySelector('.sb-track-status');
const states = ['green', 'sc', 'yellow', 'red'];
const labels = { green: 'GREEN', sc: 'SC', yellow: 'VSC', red: 'RED' };
const cur = states.findIndex(x => s.classList.contains(x));
const next = states[(cur + 1) % states.length];
states.forEach(st => s.classList.remove(st));
s.classList.add(next);
s.textContent = labels[next];
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,847 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>box-box — Race Hub · Monaco GP 2025</title>
<link rel="stylesheet" href="styles.css">
<style>
/* ── Race Hub sub-header ── */
.rh-header {
display: flex;
align-items: center;
gap: var(--s4);
padding: var(--s3) var(--s5);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-wrap: wrap;
flex-shrink: 0;
}
.rh-breadcrumb {
display: flex;
align-items: center;
gap: var(--s2);
font-size: 12px;
}
.rh-breadcrumb a { color: var(--c-text-3); }
.rh-breadcrumb a:hover { color: var(--c-text-2); }
.rh-breadcrumb .sep { color: var(--c-text-3); }
.rh-breadcrumb .cur { font-weight: 600; color: var(--c-text); }
.rh-session-btns {
display: flex;
gap: 2px;
margin-left: auto;
}
.rh-session-btn {
padding: 3px 8px;
background: none;
border: 1px solid var(--c-border);
border-radius: 2px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--c-text-3);
text-transform: uppercase;
}
.rh-session-btn.active {
background: var(--c-surface-2);
border-color: var(--c-border-2);
color: var(--c-text);
}
/* ── Section tabs (in rh-main) ── */
.rh-section-tabs {
display: flex;
gap: 2px;
padding: var(--s3) var(--s5);
border-bottom: 1px solid var(--c-border);
overflow-x: auto;
flex-shrink: 0;
}
/* ── Main content area ── */
.rh-content {
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--sec-gap);
max-width: 860px;
}
/* ── Classification ── */
.result-pos { font-family: var(--f-mono); font-size: 14px; font-weight: 700; text-align: center; width: 28px; }
/* ── Strategy ── */
#strategy-chart { width: 100%; min-height: 320px; }
.compound-legend {
display: flex;
gap: var(--s4);
margin-top: var(--s3);
flex-wrap: wrap;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--c-text-3); }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
/* ── Position Evolution ── */
#position-chart { width: 100%; }
/* ── Race Control ── */
.rc-event {
display: grid;
grid-template-columns: 52px 90px 1fr;
gap: var(--s3);
padding: 7px 0;
border-bottom: 1px solid var(--c-border);
font-size: 11px;
align-items: baseline;
}
.rc-event:last-child { border-bottom: none; }
.rc-event-lap { font-family: var(--f-mono); font-weight: 600; color: var(--c-text-3); }
.rc-event-type { font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.rc-event-msg { color: var(--c-text-2); line-height: 1.4; }
.rc-event.sc .rc-event-type { color: var(--c-yellow); }
.rc-event.vsc .rc-event-type { color: var(--c-yellow); }
.rc-event.drs .rc-event-type { color: var(--c-green); }
.rc-event.flag .rc-event-type { color: var(--c-red); }
.rc-event.fl .rc-event-type { color: var(--c-purple); }
.rc-event.info .rc-event-type { color: var(--c-text-3); }
/* ── Weather ── */
.wx-row {
display: grid;
grid-template-columns: 50px repeat(6, 1fr);
gap: var(--s3);
padding: 5px 0;
border-bottom: 1px solid var(--c-border);
font-size: 11px;
font-family: var(--f-mono);
align-items: center;
}
.wx-row:last-child { border-bottom: none; }
.wx-hdr {
font-family: var(--f-ui);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--c-text-3);
}
/* ── Dataset ── */
.ds-grid-main {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1px;
border: 1px solid var(--c-border);
margin-bottom: var(--s4);
}
.ds-cell {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s3) var(--s4);
background: var(--c-surface);
font-size: 11px;
}
.ds-cell-name { flex: 1; color: var(--c-text-2); }
.ds-cell-time { font-family: var(--f-mono); font-size: 10px; color: var(--c-text-3); }
/* ── Aside: condensed RC ── */
.rc-condensed { display: flex; flex-direction: column; }
.rc-cond-row {
display: grid;
grid-template-columns: 44px 1fr;
gap: var(--s2);
padding: 5px 0;
border-bottom: 1px solid var(--c-border);
font-size: 11px;
}
.rc-cond-row:last-child { border-bottom: none; }
.rc-cond-lap { font-family: var(--f-mono); color: var(--c-text-3); font-size: 10px; }
.rc-cond-msg { color: var(--c-text-2); line-height: 1.4; }
.rc-cond-row.sc .rc-cond-msg { color: var(--c-yellow); }
.rc-cond-row.vsc .rc-cond-msg { color: var(--c-yellow); }
.rc-cond-row.drs .rc-cond-msg { color: var(--c-green); }
.rc-cond-row.fl .rc-cond-msg { color: var(--c-purple); }
.rc-cond-row.flag .rc-cond-msg { color: var(--c-red); }
/* ── Aside: dataset mini ── */
.ds-mini { display: flex; flex-direction: column; gap: 3px; }
.ds-mini-row {
display: flex;
align-items: center;
gap: var(--s2);
font-size: 10px;
font-family: var(--f-mono);
color: var(--c-text-3);
}
.ds-mini-row .dot { flex-shrink: 0; }
</style>
</head>
<body>
<nav class="app-nav">
<a href="index.html" class="nav-logo">box<em>-</em>box</a>
<div class="nav-links">
<a href="command-center.html">Command Center</a>
<a href="live-timing.html">Live</a>
<a href="race-hub.html" class="active">Race Hub</a>
<a href="data-library.html">Data Library</a>
</div>
<div class="nav-right">
<div class="density-toggle">
<button class="active" onclick="setDensity('default',this)">D</button>
<button onclick="setDensity('compact',this)">C</button>
</div>
</div>
</nav>
<!-- Sub-header -->
<div class="rh-header">
<div class="rh-breadcrumb">
<a href="command-center.html">2025</a>
<span class="sep"></span>
<a href="#">Monaco GP</a>
<span class="sep"></span>
<span class="cur">Race</span>
</div>
<div style="display:flex;align-items:center;gap:var(--s3);font-size:12px">
<span class="t3 mono">78 Laps · 260.286 km · 25 May 2025</span>
<span class="badge local">Local data</span>
</div>
<div class="rh-session-btns">
<button class="rh-session-btn">FP1</button>
<button class="rh-session-btn">FP2</button>
<button class="rh-session-btn">FP3</button>
<button class="rh-session-btn">Qual</button>
<button class="rh-session-btn active">Race</button>
</div>
</div>
<!-- ── 2-column body ── -->
<div class="rh-body">
<!-- Left: tabbed content -->
<div style="display:flex;flex-direction:column;overflow:hidden">
<!-- Section tabs -->
<div class="rh-section-tabs">
<button class="tab-btn active" onclick="showTab('classification',this)">Classification</button>
<button class="tab-btn" onclick="showTab('strategy',this)">Strategy</button>
<button class="tab-btn" onclick="showTab('positions',this)">Positions</button>
<button class="tab-btn" onclick="showTab('racecontrol',this)">Race Control</button>
<button class="tab-btn" onclick="showTab('weather',this)">Weather</button>
<button class="tab-btn" onclick="showTab('dataset',this)">Dataset</button>
</div>
<!-- Tab panels -->
<div class="rh-content">
<!-- CLASSIFICATION -->
<div id="tab-classification" class="tab-panel active">
<div class="sec-header">
<span class="sec-title">Final Classification</span>
<span class="sec-meta">78 laps · 1:32:14.456</span>
</div>
<div class="scroll-x">
<table class="data-table" style="min-width:620px">
<thead>
<tr>
<th class="center" style="width:28px">P</th>
<th>Driver</th>
<th>Team</th>
<th class="center">Grid</th>
<th class="center">Δ</th>
<th class="num">Time / Gap</th>
<th class="num">Fastest Lap</th>
<th class="num">Pts</th>
</tr>
</thead>
<tbody>
<tr>
<td class="center"><span class="pos-p1">1</span></td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-fer)"></div>
<span class="drv-code">LEC</span><span class="drv-num">16</span>
</div>
</td>
<td class="t2">Ferrari</td>
<td class="center t3 mono">3</td>
<td class="center"><span class="pos-gain">↑2</span></td>
<td class="num mono">1:32:14.456</td>
<td class="num mono t3">1:14.892</td>
<td class="num" style="font-weight:700">25</td>
</tr>
<tr>
<td class="center"><span class="pos-p2">2</span></td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-rb)"></div>
<span class="drv-code">VER</span><span class="drv-num">1</span>
</div>
</td>
<td class="t2">Red Bull</td>
<td class="center t3 mono">1</td>
<td class="center"><span class="pos-loss">↓1</span></td>
<td class="num mono t2">+3.456</td>
<td class="num mono t3">1:15.023</td>
<td class="num" style="font-weight:700">18</td>
</tr>
<tr>
<td class="center"><span class="pos-p3">3</span></td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-mcl)"></div>
<span class="drv-code">NOR</span><span class="drv-num">4</span>
</div>
</td>
<td class="t2">McLaren</td>
<td class="center t3 mono">4</td>
<td class="center"><span class="pos-gain">↑1</span></td>
<td class="num mono t2">+8.123</td>
<td class="num mono" style="color:var(--c-purple)">1:14.756 ●</td>
<td class="num" style="font-weight:700">15</td>
</tr>
<tr>
<td class="center t3 mono">4</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-mcl)"></div>
<span class="drv-code">PIA</span><span class="drv-num">81</span>
</div>
</td>
<td class="t2">McLaren</td>
<td class="center t3 mono">6</td>
<td class="center"><span class="pos-gain">↑2</span></td>
<td class="num mono t2">+12.345</td>
<td class="num mono t3">1:15.234</td>
<td class="num t2">12</td>
</tr>
<tr>
<td class="center t3 mono">5</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-mer)"></div>
<span class="drv-code">RUS</span><span class="drv-num">63</span>
</div>
</td>
<td class="t2">Mercedes</td>
<td class="center t3 mono">2</td>
<td class="center"><span class="pos-loss">↓3</span></td>
<td class="num mono t2">+15.678</td>
<td class="num mono t3">1:15.456</td>
<td class="num t2">10</td>
</tr>
<tr>
<td class="center t3 mono">6</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-fer)"></div>
<span class="drv-code">HAM</span><span class="drv-num">44</span>
</div>
</td>
<td class="t2">Ferrari</td>
<td class="center t3 mono">5</td>
<td class="center"><span class="pos-loss">↓1</span></td>
<td class="num mono t2">+21.234</td>
<td class="num mono t3">1:15.890</td>
<td class="num t2">8</td>
</tr>
<tr>
<td class="center t3 mono">7</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-am)"></div>
<span class="drv-code">ALO</span><span class="drv-num">14</span>
</div>
</td>
<td class="t2">Aston Martin</td>
<td class="center t3 mono">8</td>
<td class="center"><span class="pos-gain">↑1</span></td>
<td class="num mono t2">+28.456</td>
<td class="num mono t3">1:16.234</td>
<td class="num t2">6</td>
</tr>
<tr>
<td class="center t3 mono">8</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-wil)"></div>
<span class="drv-code">SAI</span><span class="drv-num">55</span>
</div>
</td>
<td class="t2">Williams</td>
<td class="center t3 mono">7</td>
<td class="center"><span class="pos-loss">↓1</span></td>
<td class="num mono t2">+35.789</td>
<td class="num mono t3">1:16.567</td>
<td class="num t2">4</td>
</tr>
<tr>
<td class="center t3 mono">9</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-alp)"></div>
<span class="drv-code t3">GAS</span><span class="drv-num">10</span>
</div>
</td>
<td class="t3">Alpine</td>
<td class="center t3 mono">9</td>
<td class="center"><span class="pos-same"></span></td>
<td class="num mono t3">+42.123</td>
<td class="num mono t3">1:16.890</td>
<td class="num t3">2</td>
</tr>
<tr>
<td class="center t3 mono">10</td>
<td>
<div class="drv-cell">
<div class="drv-bar" style="background:var(--t-haas)"></div>
<span class="drv-code t3">OCO</span><span class="drv-num">31</span>
</div>
</td>
<td class="t3">Haas</td>
<td class="center t3 mono">10</td>
<td class="center"><span class="pos-same"></span></td>
<td class="num mono t3">+48.456</td>
<td class="num mono t3">1:17.012</td>
<td class="num t3">1</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- STRATEGY -->
<div id="tab-strategy" class="tab-panel">
<div class="sec-header">
<span class="sec-title">Tyre Strategy</span>
<span class="sec-meta">78 laps · SC L2326 · VSC L5860</span>
</div>
<div id="strategy-chart"></div>
<div class="compound-legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--tyre-s)"></div>Soft</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--tyre-m)"></div>Medium</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--tyre-h)"></div>Hard</div>
<div class="legend-item" style="margin-left:var(--s4)">
<div style="width:14px;height:2px;background:rgba(255,214,0,0.45);border-radius:1px"></div>
<span>SC / VSC period</span>
</div>
</div>
</div>
<!-- POSITIONS -->
<div id="tab-positions" class="tab-panel">
<div class="sec-header">
<span class="sec-title">Position Evolution</span>
<span class="sec-meta">Top 6 · SC L2326 · VSC L5860</span>
</div>
<div id="position-chart"></div>
<div class="compound-legend" style="margin-top:var(--s3)">
<div class="legend-item"><div class="legend-dot" style="background:var(--t-fer)"></div>LEC</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--t-rb)"></div>VER</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--t-mcl)"></div>NOR</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--t-mcl);opacity:.55"></div>PIA</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--t-mer)"></div>RUS</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--t-mer);opacity:.55"></div>HAM</div>
</div>
</div>
<!-- RACE CONTROL -->
<div id="tab-racecontrol" class="tab-panel">
<div class="sec-header">
<span class="sec-title">Race Control</span>
<span class="sec-meta">14 messages</span>
</div>
<div>
<div class="rc-event info">
<span class="rc-event-lap">L1</span>
<span class="rc-event-type">Start</span>
<span class="rc-event-msg">RACE START — Track Clear</span>
</div>
<div class="rc-event drs">
<span class="rc-event-lap">L3</span>
<span class="rc-event-type">DRS</span>
<span class="rc-event-msg">DRS ENABLED</span>
</div>
<div class="rc-event info">
<span class="rc-event-lap">L18</span>
<span class="rc-event-type">Incident</span>
<span class="rc-event-msg">ALB/STR at T10 under investigation</span>
</div>
<div class="rc-event sc">
<span class="rc-event-lap">L23</span>
<span class="rc-event-type">Safety Car</span>
<span class="rc-event-msg">SAFETY CAR DEPLOYED — Incident T10 (ALB retirement)</span>
</div>
<div class="rc-event sc">
<span class="rc-event-lap">L26</span>
<span class="rc-event-type">Safety Car</span>
<span class="rc-event-msg">SAFETY CAR IN THIS LAP</span>
</div>
<div class="rc-event drs">
<span class="rc-event-lap">L27</span>
<span class="rc-event-type">DRS</span>
<span class="rc-event-msg">DRS ENABLED — Lap 27</span>
</div>
<div class="rc-event info">
<span class="rc-event-lap">L35</span>
<span class="rc-event-type">Penalty</span>
<span class="rc-event-msg">5-SECOND PENALTY — RUS · Unsafe release pit lane</span>
</div>
<div class="rc-event fl">
<span class="rc-event-lap">L38</span>
<span class="rc-event-type">Fastest Lap</span>
<span class="rc-event-msg">NOR · 1:14.756</span>
</div>
<div class="rc-event vsc">
<span class="rc-event-lap">L58</span>
<span class="rc-event-type">VSC</span>
<span class="rc-event-msg">VIRTUAL SAFETY CAR DEPLOYED — Debris T6</span>
</div>
<div class="rc-event vsc">
<span class="rc-event-lap">L60</span>
<span class="rc-event-type">VSC</span>
<span class="rc-event-msg">VIRTUAL SAFETY CAR ENDING</span>
</div>
<div class="rc-event drs">
<span class="rc-event-lap">L61</span>
<span class="rc-event-type">DRS</span>
<span class="rc-event-msg">DRS ENABLED — Lap 61</span>
</div>
<div class="rc-event flag">
<span class="rc-event-lap">L78</span>
<span class="rc-event-type">Finish</span>
<span class="rc-event-msg">CHEQUERED FLAG — Race complete</span>
</div>
</div>
</div>
<!-- WEATHER -->
<div id="tab-weather" class="tab-panel">
<div class="sec-header">
<span class="sec-title">Weather</span>
<span class="sec-meta">Race · sampled every 5 laps · highlighted = SC/VSC</span>
</div>
<div class="scroll-x">
<div class="wx-row"><span class="wx-hdr">Lap</span><span class="wx-hdr">Air °C</span><span class="wx-hdr">Track °C</span><span class="wx-hdr">Humid %</span><span class="wx-hdr">Wind km/h</span><span class="wx-hdr">Dir</span><span class="wx-hdr">Rain</span></div>
<div class="wx-row"><span class="t3">1</span><span>22.4</span><span>32.1</span><span>65</span><span>6</span><span class="t3">NW</span><span class="t-green">0%</span></div>
<div class="wx-row"><span class="t3">10</span><span>23.1</span><span>34.2</span><span>63</span><span>7</span><span class="t3">NW</span><span class="t-green">0%</span></div>
<div class="wx-row"><span class="t3">20</span><span>23.8</span><span>35.6</span><span>62</span><span>6</span><span class="t3">N</span><span class="t-green">1%</span></div>
<div class="wx-row" style="background:rgba(255,214,0,0.05)"><span class="t-yellow">23</span><span>24.0</span><span>36.0</span><span>62</span><span>7</span><span class="t3">N</span><span class="t-green">1%</span></div>
<div class="wx-row"><span class="t3">30</span><span>24.3</span><span>36.8</span><span>61</span><span>8</span><span class="t3">NW</span><span class="t-green">2%</span></div>
<div class="wx-row"><span class="t3">40</span><span>24.9</span><span>37.4</span><span>60</span><span>8</span><span class="t3">NW</span><span class="t-green">2%</span></div>
<div class="wx-row"><span class="t3">50</span><span>25.2</span><span>38.1</span><span>59</span><span>9</span><span class="t3">W</span><span class="t-green">3%</span></div>
<div class="wx-row" style="background:rgba(255,214,0,0.05)"><span class="t-yellow">58</span><span>25.5</span><span>38.3</span><span>59</span><span>8</span><span class="t3">W</span><span class="t-green">3%</span></div>
<div class="wx-row"><span class="t3">70</span><span>25.8</span><span>38.6</span><span>58</span><span>7</span><span class="t3">W</span><span class="t-green">4%</span></div>
<div class="wx-row"><span class="t3">78</span><span>26.1</span><span>38.8</span><span>57</span><span>7</span><span class="t3">NW</span><span class="t-green">4%</span></div>
</div>
</div>
<!-- DATASET -->
<div id="tab-dataset" class="tab-panel">
<div class="sec-header">
<span class="sec-title">Dataset Status</span>
<span class="sec-meta">session_key 9158 · Monaco GP Race</span>
</div>
<div class="ds-grid-main">
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">session_results</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">starting_grid</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">laps</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">stints</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">pit_stops</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">positions</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">race_control</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">weather</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">overtakes</span><span class="ds-cell-time">3h ago</span></div>
<div class="ds-cell"><span class="dot local"></span><span class="ds-cell-name">track_outline</span><span class="ds-cell-time">cached</span></div>
<div class="ds-cell"><span class="dot missing"></span><span class="ds-cell-name">car_data_samples</span><span class="ds-cell-time t3">not ingested</span></div>
<div class="ds-cell"><span class="dot missing"></span><span class="ds-cell-name">location_samples</span><span class="ds-cell-time t3">not ingested</span></div>
<div class="ds-cell"><span class="dot missing"></span><span class="ds-cell-name">team_radio</span><span class="ds-cell-time t3">not ingested</span></div>
</div>
<div class="cli-block">
<div class="comment"># Ingest telemetry (high-volume, explicit only)</div>
<div class="cmd">box-box --ingest-session 9158 --datasets car_data,location</div>
<br>
<div class="comment"># Refresh all datasets for this session</div>
<div class="cmd">box-box --ingest-session 9158 --refresh</div>
</div>
</div>
</div><!-- /rh-content -->
</div><!-- /rh-main -->
<!-- ── Persistent right aside ── -->
<aside class="rh-aside">
<!-- Podium -->
<div>
<div class="sec-header">
<span class="sec-title">Podium</span>
</div>
<div class="podium-list">
<div class="podium-item">
<span class="podium-p pos-p1">P1</span>
<div class="drv-cell" style="gap:var(--s2)">
<div class="drv-bar" style="background:var(--t-fer);height:16px"></div>
<span class="drv-code" style="font-size:13px">LEC</span>
</div>
<span class="podium-gap-val">1:32:14.456</span>
</div>
<div class="podium-item">
<span class="podium-p pos-p2">P2</span>
<div class="drv-cell" style="gap:var(--s2)">
<div class="drv-bar" style="background:var(--t-rb);height:16px"></div>
<span class="drv-code" style="font-size:13px">VER</span>
</div>
<span class="podium-gap-val">+3.456</span>
</div>
<div class="podium-item">
<span class="podium-p pos-p3">P3</span>
<div class="drv-cell" style="gap:var(--s2)">
<div class="drv-bar" style="background:var(--t-mcl);height:16px"></div>
<span class="drv-code" style="font-size:13px">NOR</span>
</div>
<span class="podium-gap-val">+8.123</span>
</div>
</div>
</div>
<!-- Race Stats -->
<div>
<div class="sec-header">
<span class="sec-title">Race Stats</span>
</div>
<div>
<div class="summary-stat-row">
<span class="sstat-label">Fastest Lap</span>
<span class="sstat-val" style="color:var(--c-purple)">NOR 1:14.756 L38</span>
</div>
<div class="summary-stat-row">
<span class="sstat-label">Pole Position</span>
<span class="sstat-val">VER</span>
</div>
<div class="summary-stat-row">
<span class="sstat-label">Safety Car</span>
<span class="sstat-val t-yellow">L2326 (4 laps)</span>
</div>
<div class="summary-stat-row">
<span class="sstat-label">VSC</span>
<span class="sstat-val t-yellow">L5860 (3 laps)</span>
</div>
<div class="summary-stat-row">
<span class="sstat-label">Total Pit Stops</span>
<span class="sstat-val">22</span>
</div>
<div class="summary-stat-row">
<span class="sstat-label">DNF</span>
<span class="sstat-val">ALB (T10 crash)</span>
</div>
<div class="summary-stat-row">
<span class="sstat-label">Penalties</span>
<span class="sstat-val">RUS +5s</span>
</div>
</div>
</div>
<!-- Condensed RC -->
<div>
<div class="sec-header">
<span class="sec-title">Key Events</span>
</div>
<div class="rc-condensed">
<div class="rc-cond-row flag">
<span class="rc-cond-lap">L78</span>
<span class="rc-cond-msg">Chequered flag</span>
</div>
<div class="rc-cond-row drs">
<span class="rc-cond-lap">L61</span>
<span class="rc-cond-msg">DRS enabled</span>
</div>
<div class="rc-cond-row vsc">
<span class="rc-cond-lap">L5860</span>
<span class="rc-cond-msg">VSC — debris T6</span>
</div>
<div class="rc-cond-row fl">
<span class="rc-cond-lap">L38</span>
<span class="rc-cond-msg">FL: NOR 1:14.756</span>
</div>
<div class="rc-cond-row drs">
<span class="rc-cond-lap">L27</span>
<span class="rc-cond-msg">DRS enabled</span>
</div>
<div class="rc-cond-row sc">
<span class="rc-cond-lap">L2326</span>
<span class="rc-cond-msg">SC — ALB retirement T10</span>
</div>
</div>
</div>
<!-- Dataset mini -->
<div>
<div class="sec-header">
<span class="sec-title">Data</span>
<span class="t3" style="font-size:10px;margin-left:auto">10/13 datasets</span>
</div>
<div class="ds-mini">
<div class="ds-mini-row"><span class="dot local"></span>session_results · laps · stints</div>
<div class="ds-mini-row"><span class="dot local"></span>pit_stops · positions · race_control</div>
<div class="ds-mini-row"><span class="dot local"></span>weather · overtakes · track_outline</div>
<div class="ds-mini-row"><span class="dot missing"></span>car_data · location · team_radio</div>
</div>
</div>
</aside><!-- /rh-aside -->
</div><!-- /rh-body -->
<script>
function setDensity(mode, btn) {
document.querySelectorAll('.density-toggle button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.documentElement.classList.toggle('compact', mode === 'compact');
renderCharts();
}
function showTab(id, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + id).classList.add('active');
if (id === 'strategy' || id === 'positions') renderCharts();
}
/* ── Strategy Chart ── */
function renderStrategyChart() {
const el = document.getElementById('strategy-chart');
if (!el) return;
const TOTAL = 78;
const compounds = {
S: { fill: '#e8002d', text: '#fff' },
M: { fill: '#c8b400', text: '#000' },
H: { fill: '#909090', text: '#fff' }
};
const drivers = [
{ code: 'LEC', color: '#e8002d', stints: [{c:'S',s:1,e:20},{c:'M',s:21,e:55},{c:'H',s:56,e:78}] },
{ code: 'VER', color: '#3671c6', stints: [{c:'M',s:1,e:28},{c:'H',s:29,e:60},{c:'M',s:61,e:78}] },
{ code: 'NOR', color: '#ff8000', stints: [{c:'S',s:1,e:18},{c:'M',s:19,e:52},{c:'H',s:53,e:78}] },
{ code: 'PIA', color: '#cc6600', stints: [{c:'S',s:1,e:22},{c:'M',s:23,e:50},{c:'H',s:51,e:78}] },
{ code: 'RUS', color: '#27f4d2', stints: [{c:'M',s:1,e:30},{c:'H',s:31,e:62},{c:'S',s:63,e:78}] },
{ code: 'HAM', color: '#1abfa8', stints: [{c:'S',s:1,e:15},{c:'M',s:16,e:55},{c:'H',s:56,e:78}] },
{ code: 'ALO', color: '#229971', stints: [{c:'M',s:1,e:35},{c:'H',s:36,e:78}] },
{ code: 'SAI', color: '#64c4ff', stints: [{c:'S',s:1,e:20},{c:'M',s:21,e:58},{c:'S',s:59,e:78}] },
{ code: 'GAS', color: '#cc6699', stints: [{c:'M',s:1,e:28},{c:'H',s:29,e:65},{c:'S',s:66,e:78}] },
{ code: 'OCO', color: '#909090', stints: [{c:'S',s:1,e:20},{c:'M',s:21,e:55},{c:'H',s:56,e:78}] },
];
const ROW_H = 26, ROW_GAP = 7, LBL = 48, PAD = 8;
const W = el.clientWidth || 720;
const CHART_W = W - LBL - PAD;
const H = drivers.length * (ROW_H + ROW_GAP) + 24;
const lx = l => LBL + ((l - 1) / TOTAL) * CHART_W;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">`;
svg += `<rect x="${lx(23)}" y="0" width="${lx(27)-lx(23)}" height="${drivers.length*(ROW_H+ROW_GAP)}" fill="rgba(255,214,0,0.07)"/>`;
svg += `<rect x="${lx(58)}" y="0" width="${lx(61)-lx(58)}" height="${drivers.length*(ROW_H+ROW_GAP)}" fill="rgba(255,214,0,0.05)"/>`;
[1,10,20,30,40,50,60,70,78].forEach(l => {
svg += `<text x="${lx(l)}" y="${H-4}" text-anchor="middle" font-size="9" fill="#4a4a4a" font-family="monospace">L${l}</text>`;
});
drivers.forEach((d, i) => {
const y = i * (ROW_H + ROW_GAP);
svg += `<text x="${LBL-5}" y="${y+ROW_H/2+4}" text-anchor="end" font-size="11" font-weight="700" fill="${d.color}" font-family="monospace">${d.code}</text>`;
d.stints.forEach(st => {
const x1 = lx(st.s), x2 = lx(st.e+1), w = x2 - x1 - 1;
const laps = st.e - st.s + 1, c = compounds[st.c];
svg += `<rect x="${x1}" y="${y}" width="${w}" height="${ROW_H}" fill="${c.fill}" rx="1"/>`;
if (w > 28) svg += `<text x="${x1+w/2}" y="${y+ROW_H/2+4}" text-anchor="middle" font-size="10" font-weight="700" fill="${c.text}" font-family="monospace">${st.c} ${laps}</text>`;
else if (w > 10) svg += `<text x="${x1+w/2}" y="${y+ROW_H/2+4}" text-anchor="middle" font-size="9" font-weight="700" fill="${c.text}" font-family="monospace">${st.c}</text>`;
});
});
svg += '</svg>';
el.innerHTML = svg;
}
/* ── Position Evolution ── */
function renderPositionChart() {
const el = document.getElementById('position-chart');
if (!el) return;
const W = el.clientWidth || 720, H = 200;
const PAD = { t: 12, r: 40, b: 28, l: 28 };
const CW = W - PAD.l - PAD.r, CH = H - PAD.t - PAD.b;
const LAPS = 78, POSITIONS = 8;
const xp = l => PAD.l + (l / LAPS) * CW;
const yp = p => PAD.t + ((p - 1) / (POSITIONS - 1)) * CH;
const drivers = [
{ code: 'LEC', color: '#e8002d', dash: false, pts: [{l:0,p:3},{l:2,p:1},{l:78,p:1}] },
{ code: 'VER', color: '#3671c6', dash: false, pts: [{l:0,p:1},{l:2,p:2},{l:78,p:2}] },
{ code: 'NOR', color: '#ff8000', dash: false, pts: [{l:0,p:4},{l:6,p:3},{l:78,p:3}] },
{ code: 'PIA', color: '#ff8000', dash: true, pts: [{l:0,p:6},{l:10,p:4},{l:78,p:4}] },
{ code: 'RUS', color: '#27f4d2', dash: false, pts: [{l:0,p:2},{l:2,p:5},{l:78,p:5}] },
{ code: 'HAM', color: '#27f4d2', dash: true, pts: [{l:0,p:5},{l:78,p:6}] },
];
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">`;
svg += `<rect x="${xp(23)}" y="${PAD.t}" width="${xp(26)-xp(23)}" height="${CH}" fill="rgba(255,214,0,0.08)"/>`;
svg += `<rect x="${xp(58)}" y="${PAD.t}" width="${xp(60)-xp(58)}" height="${CH}" fill="rgba(255,214,0,0.05)"/>`;
svg += `<text x="${xp(24.5)}" y="${PAD.t+10}" text-anchor="middle" font-size="8" fill="#6a5800" font-family="sans-serif" font-weight="700">SC</text>`;
svg += `<text x="${xp(59)}" y="${PAD.t+10}" text-anchor="middle" font-size="8" fill="#6a5800" font-family="sans-serif" font-weight="700">VSC</text>`;
for (let p = 1; p <= POSITIONS; p++) {
svg += `<line x1="${PAD.l}" y1="${yp(p)}" x2="${W-PAD.r}" y2="${yp(p)}" stroke="#1e1e1e" stroke-width="1"/>`;
svg += `<text x="${PAD.l-4}" y="${yp(p)+3}" text-anchor="end" font-size="9" fill="#4a4a4a" font-family="monospace">P${p}</text>`;
}
[0,10,20,30,40,50,60,70,78].forEach(l => {
svg += `<text x="${xp(l)}" y="${H-4}" text-anchor="middle" font-size="9" fill="#4a4a4a" font-family="monospace">L${l||1}</text>`;
});
drivers.forEach(d => {
const pts = d.pts.map(p => `${xp(p.l)},${yp(p.p)}`).join(' ');
const dash = d.dash ? 'stroke-dasharray="5,3"' : '';
svg += `<polyline points="${pts}" stroke="${d.color}" stroke-width="1.5" fill="none" ${dash} stroke-opacity="0.9"/>`;
const last = d.pts[d.pts.length - 1];
svg += `<text x="${xp(last.l)+4}" y="${yp(last.p)+4}" font-size="9" fill="${d.color}" font-family="monospace" font-weight="700">${d.code}</text>`;
});
svg += '</svg>';
el.innerHTML = svg;
}
function renderCharts() {
renderStrategyChart();
renderPositionChart();
}
document.addEventListener('DOMContentLoaded', renderCharts);
window.addEventListener('resize', renderCharts);
</script>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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.

471
internal/ingest/ingest.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

270
internal/ingest/openf1.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

82
internal/live/signalr.go Normal file
View File

@@ -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
}

495
internal/live/state.go Normal file
View File

@@ -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
}

144
internal/live/types.go Normal file
View File

@@ -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
}

92
internal/store/db.go Normal file
View File

@@ -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
}

295
internal/store/meetings.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);

106
internal/store/models.go Normal file
View File

@@ -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
}

177
internal/store/raw.go Normal file
View File

@@ -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
}

302
internal/store/results.go Normal file
View File

@@ -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
}

31
internal/store/runs.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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")