mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add local-first refactor foundation
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ build/
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
|
||||
60
cmd/main.go
60
cmd/main.go
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/ingest"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
"github.com/AmanTahiliani/box-box/internal/ui"
|
||||
"github.com/AmanTahiliani/box-box/internal/web"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -16,6 +18,11 @@ import (
|
||||
func main() {
|
||||
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
||||
port := flag.Int("port", 8080, "Port for web server (used with --web)")
|
||||
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
|
||||
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest OpenF1 sessions for a meeting key")
|
||||
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
||||
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
||||
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
|
||||
flag.Parse()
|
||||
|
||||
var client *api.OpenF1Client
|
||||
@@ -29,6 +36,28 @@ func main() {
|
||||
// Clean up old file-based cache (one-time migration).
|
||||
go api.CleanupOldFileCache()
|
||||
|
||||
ingestFlags := 0
|
||||
if *ingestYear != 0 {
|
||||
ingestFlags++
|
||||
}
|
||||
if *ingestMeeting != 0 {
|
||||
ingestFlags++
|
||||
}
|
||||
if *ingestSession != 0 {
|
||||
ingestFlags++
|
||||
}
|
||||
if ingestFlags > 0 {
|
||||
if ingestFlags > 1 {
|
||||
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --ingest-meeting, or --ingest-session may be set")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := runIngestion(client, *ingestYear, *ingestMeeting, *ingestSession, *dryRun, *dbPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *webMode {
|
||||
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
|
||||
fmt.Printf("box-box web → http://localhost:%d\n", *port)
|
||||
@@ -56,3 +85,34 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, dryRun bool, dbPath string) error {
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
path := dbPath
|
||||
if path == "" {
|
||||
path = store.DefaultDBPath()
|
||||
}
|
||||
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open domain database: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
opts := ingest.DefaultOptions()
|
||||
opts.DryRun = dryRun
|
||||
opts.Progress = ingest.NewProgress(os.Stderr)
|
||||
|
||||
svc := ingest.NewService(st, ingest.NewOpenF1Source(client), opts)
|
||||
|
||||
switch {
|
||||
case year != 0:
|
||||
_, err = svc.IngestYear(year)
|
||||
case meetingKey != 0:
|
||||
_, err = svc.IngestMeeting(meetingKey)
|
||||
case sessionKey != 0:
|
||||
_, err = svc.IngestSession(sessionKey)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
179
documentations/refactor/01-data-sources.md
Normal file
179
documentations/refactor/01-data-sources.md
Normal 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?
|
||||
|
||||
216
documentations/refactor/02-backend-architecture.md
Normal file
216
documentations/refactor/02-backend-architecture.md
Normal 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?
|
||||
195
documentations/refactor/03-database-design.md
Normal file
195
documentations/refactor/03-database-design.md
Normal 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.
|
||||
231
documentations/refactor/04-web-ui-product.md
Normal file
231
documentations/refactor/04-web-ui-product.md
Normal 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.
|
||||
|
||||
152
documentations/refactor/05-frontend-stack.md
Normal file
152
documentations/refactor/05-frontend-stack.md
Normal 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.
|
||||
|
||||
123
documentations/refactor/06-visual-design-direction.md
Normal file
123
documentations/refactor/06-visual-design-direction.md
Normal 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.
|
||||
|
||||
206
documentations/refactor/07-research-agents-brief.md
Normal file
206
documentations/refactor/07-research-agents-brief.md
Normal 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?
|
||||
176
documentations/refactor/08-v1-scope-and-phasing.md
Normal file
176
documentations/refactor/08-v1-scope-and-phasing.md
Normal 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.
|
||||
195
documentations/refactor/09-phase-1-live-extraction.md
Normal file
195
documentations/refactor/09-phase-1-live-extraction.md
Normal 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.
|
||||
136
documentations/refactor/10-phase-2-store-foundation.md
Normal file
136
documentations/refactor/10-phase-2-store-foundation.md
Normal 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.
|
||||
163
documentations/refactor/11-phase-3-ingestion-foundation.md
Normal file
163
documentations/refactor/11-phase-3-ingestion-foundation.md
Normal 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.
|
||||
139
documentations/refactor/12-phase-4-local-first-web-api.md
Normal file
139
documentations/refactor/12-phase-4-local-first-web-api.md
Normal 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.
|
||||
86
documentations/refactor/README.md
Normal file
86
documentations/refactor/README.md
Normal 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.
|
||||
@@ -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.
|
||||
683
documentations/refactor/screens/command-center.html
Normal file
683
documentations/refactor/screens/command-center.html
Normal 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 · 22–25 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 P1–P2</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>
|
||||
375
documentations/refactor/screens/component-notes.md
Normal file
375
documentations/refactor/screens/component-notes.md
Normal 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 1–2 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 (769–1024px): `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.
|
||||
651
documentations/refactor/screens/data-library.html
Normal file
651
documentations/refactor/screens/data-library.html
Normal 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>
|
||||
180
documentations/refactor/screens/index.html
Normal file
180
documentations/refactor/screens/index.html
Normal 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>
|
||||
774
documentations/refactor/screens/live-timing.html
Normal file
774
documentations/refactor/screens/live-timing.html
Normal 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>
|
||||
939
documentations/refactor/screens/mobile-live.html
Normal file
939
documentations/refactor/screens/mobile-live.html
Normal 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>
|
||||
847
documentations/refactor/screens/race-hub.html
Normal file
847
documentations/refactor/screens/race-hub.html
Normal 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 L23–26 · VSC L58–60</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 L23–26 · VSC L58–60</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">L23–26 (4 laps)</span>
|
||||
</div>
|
||||
<div class="summary-stat-row">
|
||||
<span class="sstat-label">VSC</span>
|
||||
<span class="sstat-val t-yellow">L58–60 (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">L58–60</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">L23–26</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>
|
||||
779
documentations/refactor/screens/styles.css
Normal file
779
documentations/refactor/screens/styles.css
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
471
internal/ingest/ingest.go
Normal 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
|
||||
}
|
||||
381
internal/ingest/ingest_test.go
Normal file
381
internal/ingest/ingest_test.go
Normal 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
270
internal/ingest/openf1.go
Normal 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)
|
||||
}
|
||||
49
internal/ingest/progress.go
Normal file
49
internal/ingest/progress.go
Normal 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)
|
||||
}
|
||||
}
|
||||
323
internal/live/parser_test.go
Normal file
323
internal/live/parser_test.go
Normal 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
82
internal/live/signalr.go
Normal 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
495
internal/live/state.go
Normal 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
144
internal/live/types.go
Normal 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
92
internal/store/db.go
Normal 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
295
internal/store/meetings.go
Normal 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
|
||||
}
|
||||
92
internal/store/migrations.go
Normal file
92
internal/store/migrations.go
Normal 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
|
||||
}
|
||||
118
internal/store/migrations/001_initial.sql
Normal file
118
internal/store/migrations/001_initial.sql
Normal 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
106
internal/store/models.go
Normal 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
177
internal/store/raw.go
Normal 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
302
internal/store/results.go
Normal 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
31
internal/store/runs.go
Normal 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
|
||||
}
|
||||
450
internal/store/store_test.go
Normal file
450
internal/store/store_test.go
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user