mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add local-first refactor foundation
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user