Compare commits

...

35 Commits

Author SHA1 Message Date
AmanTahiliani
024accfb3d Add season calendar to command center 2026-05-25 18:14:55 -04:00
AmanTahiliani
a2aa772838 Add race control visuals and driver names in laps view 2026-05-25 15:55:36 -04:00
AmanTahiliani
3eb74a9083 Paginate briefing cards and add video previews 2026-05-25 15:27:34 -04:00
AmanTahiliani
e3453de788 feat(paddock): initial paddock briefing pages and store changes 2026-05-25 15:16:00 -04:00
AmanTahiliani
e060bcba24 fix(web): resolve practice/qualifying partial session bug by flagging starting_grid as N/A 2026-05-25 14:33:14 -04:00
AmanTahiliani
937674f808 feat(backend): implement session coverage, resumable ingestion, deep year backfill, backoff rate-limit, and dynamic cache TTLs 2026-05-25 13:52:43 -04:00
AmanTahiliani
bc1c551a82 feat(ui): interactive race story canvas and layout refinements 2026-05-25 13:44:51 -04:00
AmanTahiliani
c25822dee0 Phase 22b: Interactive Race Story Canvas 2026-05-25 13:20:55 -04:00
AmanTahiliani
c172bb59b1 Phase 22: Race Story Deepening 2026-05-25 13:10:47 -04:00
AmanTahiliani
7ce2b1ace4 Add Paddock Briefing UI (Phase 21) 2026-05-25 13:04:29 -04:00
AmanTahiliani
3bd169c55c Rework Race Hub as weekend workspace 2026-05-25 12:35:59 -04:00
AmanTahiliani
ee88a07aa1 Add paddock briefing feed ingestion 2026-05-25 12:30:21 -04:00
AmanTahiliani
84a8827244 Add paddock briefing RSS backend spike 2026-05-25 12:02:18 -04:00
AmanTahiliani
79b0b9f469 Rework command center for race weekends 2026-05-25 11:51:16 -04:00
AmanTahiliani
a0f135aac1 Update operator documentation 2026-05-25 11:26:52 -04:00
AmanTahiliani
9532206522 Polish live timing UI 2026-05-25 10:45:35 -04:00
AmanTahiliani
e539abcc5f Add command center screen 2026-05-25 10:29:31 -04:00
AmanTahiliani
571edb9b5a Add visual regression coverage 2026-05-25 10:17:07 -04:00
AmanTahiliani
10729a906c Add production web smoke coverage 2026-05-25 10:04:11 -04:00
AmanTahiliani
17c94d83ad Tighten race result ingestion ordering 2026-05-25 10:03:57 -04:00
AmanTahiliani
404029ff24 Add MVP completion checklist 2026-05-25 02:58:36 -04:00
AmanTahiliani
e410a01db1 Complete React live and Race Hub views 2026-05-25 02:56:06 -04:00
AmanTahiliani
5eb0983ffb Preserve optional analytics partial ingestion 2026-05-25 02:51:50 -04:00
AmanTahiliani
95060b07a5 Serve built React app from web mode 2026-05-25 02:49:31 -04:00
AmanTahiliani
0992fc03e8 Add data library UI 2026-05-25 02:45:02 -04:00
AmanTahiliani
f2339a00a9 Add weekend ingestion orchestration 2026-05-25 02:38:27 -04:00
AmanTahiliani
2c9db0213c Add local data navigation UI 2026-05-25 02:31:35 -04:00
AmanTahiliani
1661f8dec3 Add local navigation API 2026-05-25 02:25:06 -04:00
AmanTahiliani
b5d87775a6 Add Race Hub analytics visuals 2026-05-25 02:19:53 -04:00
AmanTahiliani
dfd0cf7c12 Tighten Phase 8 handoff prompt 2026-05-25 02:06:08 -04:00
AmanTahiliani
c7438f0ed8 Add analytics data foundation 2026-05-25 02:04:16 -04:00
AmanTahiliani
5470b0df38 Expand React race hub analytics 2026-05-25 01:20:04 -04:00
AmanTahiliani
32d500f5af Add React race hub frontend 2026-05-25 01:10:44 -04:00
AmanTahiliani
e08255db70 Add local-first race hub API 2026-05-25 00:49:13 -04:00
AmanTahiliani
517c6b987b Add local-first refactor foundation 2026-05-25 00:45:46 -04:00
178 changed files with 29378 additions and 833 deletions

16
.gitignore vendored
View File

@@ -7,6 +7,7 @@ build/
# Log files
*.log
.DS_Store
# SQLite database files
*.db
@@ -15,3 +16,18 @@ build/
# Old file cache
.cache/
# Frontend dependencies/build output
frontend/node_modules/
frontend/dist/
# Local Claude workspace settings
.claude/
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

256
README.md
View File

@@ -1,92 +1,228 @@
# 🏎️ box-box
# box-box
![Status-Pre-Beta](https://img.shields.io/badge/Status-Pre--Beta-yellow?style=for-the-badge)
> "Box, box. Box, box." — Every F1 race engineer, ever.
> "Box, box. Box, box." — Every F1 Race Engineer, ever.
**box-box** is a Formula 1 dashboard in Go with two surfaces:
**⚠️ Disclaimer: This project is currently in a pre-beta state.** Features may be incomplete, and you might encounter bugs as we fine-tune the engine.
- **Web UI (primary)** — React + TypeScript SPA for Race Hub analytics, local data coverage, and live timing.
- **TUI (preserved)** — Bubble Tea terminal app with standings, calendar, driver profiles, official live timing, track map, battles, pit window, and replay.
**box-box** is a high-performance Formula 1 Terminal User Interface (TUI) built for fans who live in the command line. Get real-time standings, race calendars, and deep-dive driver stats without ever leaving your terminal.
Historical Web data is **local-first** in a SQLite domain database (ingested from OpenF1). **Live timing** uses the official F1 SignalR feed via the Go server; the React app reads `/api/v1/live/*`, not OpenF1 directly.
![box-box TUI Preview](https://img.shields.io/badge/UI-Bubble%20Tea-00ADD8?style=for-the-badge&logo=go)
![Data-OpenF1](https://img.shields.io/badge/Data-OpenF1-FF1801?style=for-the-badge)
For architecture, phase history, and design rationale, see [documentations/refactor/README.md](documentations/refactor/README.md).
## ✨ Features
## How it fits together
- 🏆 **Live Standings**: Keep track of the Driver and Constructor Championships.
- 📅 **Race Calendar**: The full 2025 schedule at your fingertips.
- 🏎️ **Race Details**: Deep dive into session results, starting grids, and lap data.
- 👤 **Driver Profiles**: Detailed stats for every driver on the grid.
- 🔴 **Official Live Timing**: Real-time F1 timing tower via the official SignalR feed — gaps, intervals, tyre age, sector times, DRS, and track status.
- ⚔️ **Battle Tracker**: Auto-detects on-track duels within DRS range with gap sparklines and tyre strategy comparison.
- 🔧 **Pit Window Calculator**: Predicts rejoin position if a driver pits now, using per-circuit pit loss times.
- **Race Replay**: Lap-by-lap scrubber for completed races — relive the whole field's evolution with pit annotations and race control messages.
- 🗺️ **ASCII Track Map**: Live car positions on a terminal-rendered track outline, team-coloured.
- 🔌 **Offline-ish**: Fast, lightweight, and powered by the wonderful [OpenF1 API](https://openf1.org).
| Layer | Role |
| --- | --- |
| **OpenF1 REST** | Backfill and ingestion source; optional paid tier via `OPENF1_API_KEY`. Also powers the TUIs on-demand reads and HTTP cache. |
| **Domain SQLite** (`boxbox.db`) | Local store for meetings, sessions, Race Hub datasets, and navigation APIs used by the Web UI. |
| **HTTP cache SQLite** (`cache.db`) | TTL cache for OpenF1 responses (TUI and legacy paths). Separate from the domain DB. |
| **Official F1 SignalR** | Live timing bridge in `internal/live`, exposed to Web (SSE) and TUI. |
| **Go server** | `cmd/main.go` — TUI, `--web` API + static SPA, or CLI ingestion. |
| **React frontend** | `frontend/` — production build served from `frontend/dist` when present. |
## 🚀 Quick Start
The Web UI should call **local-first Go APIs** (`/api/v1/...`). Do not add direct OpenF1 reads in the frontend.
### Prerequisites
## Prerequisites
- [Go](https://go.dev/doc/install) 1.21 or higher.
- [Go](https://go.dev/doc/install) (see `go.mod` for the module version)
- [Node.js](https://nodejs.org/) 18+ and npm (Web UI dev, unit tests, Playwright)
- Internet for ingestion and TUI OpenF1 calls
- For E2E / visual tests: `npx playwright install` (Chromium) after `npm install` at the repo root
### Installation
## Install
```bash
# Clone the repository
git clone https://github.com/AmanTahiliani/box-box.git
cd box-box
# Build and run
go run cmd/main.go
npm install # Playwright and repo-level test scripts
npm install --prefix frontend # Vite + React app
```
## 🎮 Controls
## Build
```bash
go build -o box-box ./cmd/main.go
npm run build --prefix frontend # writes frontend/dist (gitignored)
```
## Run — TUI (default)
```bash
go run ./cmd/main.go
# or: ./box-box
```
Logs go to `box-box.log` in the project directory so the terminal stays clean.
### TUI keybindings
| Key | Action |
| --- | --- |
| `1` | Switch to **Home** |
| `2` | Switch to **Standings** |
| `3` | Switch to **Calendar** |
| `4` | Switch to **Race Details** |
| `5` | Switch to **Drivers** |
| `6` | Switch to **Live Timing** |
| `7` | Switch to **Track Map** |
| `tab` / `shift+tab` | Next / Previous tab |
| `j`/`↓` | Navigate down |
| `k`/`↑` | Navigate up |
| `enter` | Select/Inspect item |
| `b` / `esc` | Go back / collapse |
| `s` | Toggle sector times (Live tab) |
| `b` | Toggle Battle Tracker (Live tab) |
| `p` | Toggle Pit Window Calculator (Live tab) |
| `r` | Enter Race Replay (Race Detail tab, Race sessions) |
| `←`/`h` · `→`/`l` | Scrub laps in Replay |
| `1``7` | Home, Standings, Calendar, Race Detail, Drivers, Live, Track Map |
| `tab` / `shift+tab` | Next / previous tab |
| `j`/`k`, `enter`, `b`/`esc` | Navigate, select, back |
| `s`, `b`, `p` | Live: sectors, battles, pit window |
| `r` | Race replay (Race Detail, race sessions) |
| `y` | Cycle season year |
| `q` / `ctrl+c` | Exit |
| `q` / `ctrl+c` | Quit |
## 🛠️ Tech Stack
## Run — Web (Go serves API + built React)
- **[Bubble Tea](https://github.com/charmbracelet/bubbletea)**: The TUI engine.
- **[Lipgloss](https://github.com/charmbracelet/lipgloss)**: For that sleek F1 styling.
- **[Bubbles](https://github.com/charmbracelet/bubbles)**: Common TUI components.
- **[OpenF1 API](https://api.openf1.org)**: The data source (Free, no API key needed).
## 🚥 Development
Want to tinker under the hood?
Build the frontend first, then start web mode. Go walks up from the cwd to find `frontend/dist/index.html`; if missing, it serves embedded legacy assets.
```bash
# Run tests
go test ./...
# View API integration tests (requires internet)
go test -v ./internal/api
npm run build --prefix frontend
go run ./cmd/main.go --web
# → http://localhost:8080
```
## 📜 License
Use a specific domain database or port:
```bash
go run ./cmd/main.go --web --db ~/.local/share/box-box/boxbox.db --port 8080
```
## Run — Web dev (Vite + Go API)
Vite proxies `/api` to the Go server. Set `BOXBOX_API_PORT` to match the Go `--port`.
**Terminal 1 — API (seeded DB is enough for UI work without ingesting):**
```bash
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-dev.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-dev.db --port 18080
```
**Terminal 2 — frontend:**
```bash
BOXBOX_API_PORT=18080 npm run dev --prefix frontend
# default Vite port 5173 → http://localhost:5173
```
`BOXBOX_DISABLE_LIVE=1` skips starting the SignalR bridge (used in CI and local UI work).
## Web routes
| Route | Purpose |
| --- | --- |
| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links |
| `/race-hub?session_key=<key>` | **Race Hub** — weekend workspace with session rail and Overview / Race Story / Strategy / Lap Data / Conditions / Race Control / Data Status tabs. Bare `/race-hub` auto-resolves to the focus session. |
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands |
| `/data-library` | Legacy alias for Admin / Data Health |
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live |
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
## Ingest historical data and briefing feeds
Ingestion is a **CLI mode** on the same binary. Only one of `--ingest-year`, `--ingest-meeting`, `--ingest-session`, or `--ingest-news` may be set per run.
```bash
# Season: discover and store meeting metadata (2023+)
go run ./cmd/main.go --ingest-year 2025
# Full race weekend: all sessions + Race Hub datasets
go run ./cmd/main.go --ingest-meeting 1229
# Single session only
go run ./cmd/main.go --ingest-session 9472
# Preview without writing
go run ./cmd/main.go --dry-run --ingest-meeting 1229
# Refresh Paddock Briefing RSS/Atom feeds
go run ./cmd/main.go --ingest-news
# Custom DB path (default: ~/.local/share/box-box/boxbox.db)
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
```
Use **`--ingest-meeting`** for a complete weekend. **`--ingest-year`** lists meetings for the season; ingest meetings individually or by weekend as needed. Optional analytics fetches may partially fail without aborting the whole run.
## `OPENF1_API_KEY`
```bash
export OPENF1_API_KEY=your_key_here
go run ./cmd/main.go --web
```
Without a key, the free OpenF1 tier is used. A key may be required for paid-tier behavior (e.g. live session access during API lockouts). Ingestion and TUI calls use the same client.
## Local files
| Path | Purpose |
| --- | --- |
| `~/.local/share/box-box/boxbox.db` | Domain database (default `--db`) |
| `~/.cache/box-box/cache.db` | OpenF1 HTTP response cache (TUI / client) |
| `box-box.log` | TUI application log (project root) |
| `frontend/dist/` | Production React build (**gitignored** — build locally, do not commit) |
| `.playwright/*.db` | Seeded DBs for automated tests |
Web mode logs to **stderr**.
## Tests and QA
### Go (targeted packages)
```bash
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
```
All packages: `go test ./...`
OpenF1 integration tests (network, rate-limit aware): `go test -v ./internal/api`
### Frontend unit tests and build
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
```
### E2E (Vite dev proxy + seeded API)
Starts seeded Go on port `18080` and Vite on `15173` (see `playwright.config.ts`).
```bash
npx playwright install # first time only
npm run test:e2e
```
### E2E production (Go serves `frontend/dist`)
```bash
npm run test:e2e:prod
```
### Visual regression (screenshots)
```bash
npm run test:visual # dev proxy stack
npm run test:visual:prod # production serving (canonical baselines)
# after intentional UI changes
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/`.
## Known limitations
- **Live timing** only works when F1 is broadcasting timing data; there is no guaranteed live session for local dev.
- **E2E / visual tests** use `BOXBOX_DISABLE_LIVE=1` and seeded SQLite — they do not exercise full SignalR live behavior.
- **`frontend/dist`** is generated output; build before production web mode or `test:e2e:prod`.
- **TUI historical views** still use OpenF1 on demand with the HTTP cache; the Web UIs local-first model does not fully replace the TUI yet.
- **`--ingest-year`** stores season meetings, not full session datasets — use `--ingest-meeting` or `--ingest-session` for Race Hub data.
## License
MIT © [Aman Tahiliani](https://github.com/AmanTahiliani)
---
*Disclaimer: This project is unofficial and not associated with Formula 1 or the FIA in any way.*
*Unofficial project; not associated with Formula 1 or the FIA.*

View File

@@ -1,13 +1,19 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/ingest"
"github.com/AmanTahiliani/box-box/internal/news"
"github.com/AmanTahiliani/box-box/internal/store"
"github.com/AmanTahiliani/box-box/internal/ui"
"github.com/AmanTahiliani/box-box/internal/web"
tea "github.com/charmbracelet/bubbletea"
@@ -16,8 +22,25 @@ import (
func main() {
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
port := flag.Int("port", 8080, "Port for web server (used with --web)")
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
backfillSeason := flag.Int("backfill-season", 0, "Trigger full-season backfill/deep-ingestion for the given year")
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
flag.Parse()
if *coverageYear != 0 {
if err := runCoverageReport(*coverageYear, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "coverage report error: %v\n", err)
os.Exit(1)
}
return
}
var client *api.OpenF1Client
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey)
@@ -29,10 +52,64 @@ func main() {
// Clean up old file-based cache (one-time migration).
go api.CleanupOldFileCache()
ingestFlags := 0
if *ingestYear != 0 {
ingestFlags++
}
if *backfillSeason != 0 {
ingestFlags++
}
if *ingestMeeting != 0 {
ingestFlags++
}
if *ingestSession != 0 {
ingestFlags++
}
if *ingestNews {
ingestFlags++
}
if ingestFlags > 0 {
if ingestFlags > 1 {
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set")
os.Exit(1)
}
if *ingestNews {
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
os.Exit(1)
}
return
}
yearVal := *ingestYear
if *backfillSeason != 0 {
yearVal = *backfillSeason
}
if err := runIngestion(client, yearVal, *ingestMeeting, *ingestSession, *force, *dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
os.Exit(1)
}
return
}
if *webMode {
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
fmt.Printf("box-box web → http://localhost:%d\n", *port)
srv := web.NewServer(client, *port)
var domainStore *store.Store
db := *dbPath
if db == "" {
db = store.DefaultDBPath()
}
if st, err := store.Open(db); err != nil {
log.Printf("web: domain database unavailable (%s): %v", db, err)
} else {
domainStore = st
defer domainStore.Close()
}
srv := web.NewServer(client, *port, domainStore)
log.Fatal(srv.Start())
return
}
@@ -56,3 +133,188 @@ func main() {
os.Exit(1)
}
}
func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, force, dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr)
path := dbPath
if path == "" {
path = store.DefaultDBPath()
}
st, err := store.Open(path)
if err != nil {
return fmt.Errorf("open domain database: %w", err)
}
defer st.Close()
opts := ingest.DefaultOptions()
opts.DryRun = dryRun
opts.Force = force
opts.Progress = ingest.NewProgress(os.Stderr)
svc := ingest.NewService(st, ingest.NewOpenF1Source(client), opts)
switch {
case year != 0:
_, err = svc.IngestYear(year)
case meetingKey != 0:
_, err = svc.IngestMeeting(meetingKey)
case sessionKey != 0:
_, err = svc.IngestSession(sessionKey)
}
return err
}
func runNewsIngestion(dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr)
path := dbPath
if path == "" {
path = store.DefaultDBPath()
}
var st *store.Store
if !dryRun {
var err error
st, err = store.Open(path)
if err != nil {
return fmt.Errorf("open domain database: %w", err)
}
defer st.Close()
}
if dryRun {
fmt.Fprintf(os.Stderr, "news: dry run, not writing to %s\n", path)
} else {
fmt.Fprintf(os.Stderr, "news: refreshing feeds into %s\n", path)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := news.Refresh(ctx, st, news.RefreshOptions{
Client: &http.Client{Timeout: 10 * time.Second},
DryRun: dryRun,
Progress: os.Stderr,
EnrichOG: !dryRun,
})
fmt.Fprintf(
os.Stderr,
"news: %d source(s) fetched, %d failed, %d item(s) fetched, %d upserted\n",
result.SourcesFetched,
result.SourcesFailed,
result.ItemsFetched,
result.ItemsUpserted,
)
return err
}
func runCoverageReport(year int, dbPath string) error {
path := dbPath
if path == "" {
path = store.DefaultDBPath()
}
st, err := store.Open(path)
if err != nil {
return fmt.Errorf("open domain database: %w", err)
}
defer st.Close()
rows, err := st.GetSeasonCoverage(year)
if err != nil {
return fmt.Errorf("get season coverage: %w", err)
}
if len(rows) == 0 {
fmt.Printf("No session coverage data found for year %d.\n", year)
return nil
}
type datasetStatus struct {
Status string
Count int
}
type sessionInfo struct {
MeetingName string
SessionName string
SessionKey int
Datasets map[string]datasetStatus
}
var sessions []sessionInfo
sessionMap := make(map[int]int)
for _, row := range rows {
idx, exists := sessionMap[row.SessionKey]
if !exists {
idx = len(sessions)
sessions = append(sessions, sessionInfo{
MeetingName: row.MeetingName,
SessionName: row.SessionName,
SessionKey: row.SessionKey,
Datasets: make(map[string]datasetStatus),
})
sessionMap[row.SessionKey] = idx
}
if row.Dataset != "" {
sessions[idx].Datasets[row.Dataset] = datasetStatus{
Status: row.Status,
Count: row.RowCount,
}
}
}
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
"Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA")
fmt.Println(strings.Repeat("-", 82))
for _, sess := range sessions {
statusChar := func(ds string) string {
dsStatus, ok := sess.Datasets[ds]
if !ok {
return "."
}
switch dsStatus.Status {
case "complete":
return "✓"
case "failed":
return "✗"
default:
return "."
}
}
nameCol := fmt.Sprintf("%s - %s (%d)", sess.MeetingName, sess.SessionName, sess.SessionKey)
if len(nameCol) > 35 {
nameCol = nameCol[:32] + "..."
}
fmt.Printf("%-35s | %-5d | %s | %s | %s | %s | %s | %s | %s | %s | %s\n",
nameCol,
sess.SessionKey,
statusChar("drivers"),
statusChar("session_result"),
statusChar("starting_grid"),
statusChar("stints"),
statusChar("pit_stops"),
statusChar("positions"),
statusChar("race_control"),
statusChar("weather"),
statusChar("laps"),
)
}
fmt.Println(strings.Repeat("-", 82))
fmt.Println("\nLegend:")
fmt.Println(" [✓] Complete [✗] Failed [.] Pending/Unattempted")
fmt.Println("Datasets:")
fmt.Println(" DR: drivers SR: session_result SG: starting_grid")
fmt.Println(" ST: stints PS: pit_stops PO: positions")
fmt.Println(" RC: race_control WE: weather LA: laps")
fmt.Println()
return nil
}

View File

@@ -0,0 +1,179 @@
# Data Sources
## Summary
`box-box` should treat data sources as inputs to a local product database, not as
page-level dependencies. The current app fetches too much data on demand from
OpenF1, which breaks down during free-tier lockouts and makes non-live screens
feel empty. The live mode succeeds because it uses the official F1 live timing
feed directly.
## Confirmed Sources
### OpenF1 REST API
Reference: https://openf1.org/docs/
Current usage:
- Meetings and sessions.
- Drivers.
- Championship standings.
- Session results and starting grid.
- Laps, stints, pit stops, positions, intervals.
- Race control, weather, overtakes.
- Car data, location, team radio metadata.
Strengths:
- Good historical/session data source.
- JSON over simple HTTP.
- Broad endpoint coverage.
- Query filtering by fields and time ranges.
Limitations:
- Free-tier access can be locked during live sessions.
- On-demand fetching is unreliable as a product behavior.
- API schema or access rules can change.
- High-volume endpoints can be expensive to fetch repeatedly.
Policy:
- Use OpenF1 primarily for ingestion and backfill.
- Do not make historical Web pages depend on fresh OpenF1 calls.
- Store successful fetches into the local domain database and raw payload log.
### Official F1 SignalR Live Feed
Endpoint: https://livetiming.formula1.com/signalr
Current code connects to the old ASP.NET SignalR protocol, negotiates a
connection token, opens a websocket, and subscribes to the `Streaming` hub.
Current subscribed topics:
- `Heartbeat`
- `TimingData`
- `DriverList`
- `LapCount`
- `ExtrapolatedClock`
- `TrackStatus`
- `RaceControlMessages`
- `WeatherData`
- `SessionInfo`
- `CurrentTyres`
- `TimingAppData`
- `TimingStats`
Strengths:
- Best current source for live timing.
- Provides race-control updates quickly.
- Avoids OpenF1 REST lockout during active sessions.
- Powers the strongest part of the existing app.
Limitations:
- Payloads are less formally documented than OpenF1.
- Topic schemas can drift.
- Testing live behavior is difficult outside active sessions.
- Current parser lives in `internal/ui`, which couples live source handling to
the TUI layer.
Policy:
- Treat SignalR as the authoritative live source while a session is active.
- Extract parsing and live-state logic into reusable backend/domain code.
- Forward live state to the Web UI through SSE initially.
- Research whether live snapshots/events should be persisted.
### Existing SQLite HTTP Cache
Current location: user cache directory under `box-box/cache.db`.
Current behavior:
- Stores raw HTTP responses by URL.
- Applies TTL rules based on URL patterns.
- Can return stale responses when OpenF1 fails.
- Stores track outlines in a structured table.
Strengths:
- Useful as a fallback.
- Already integrated with the OpenF1 client.
- Reduces repeated network calls.
Limitations:
- Not a queryable domain model.
- URL keys are poor product identifiers.
- Cannot easily power analytics, replay, ingestion status, or data provenance.
- Pruning/TTL behavior is cache-oriented, not history-oriented.
Policy:
- Keep the raw cache as a fallback layer.
- Do not use it as the primary application database.
- Add a separate domain schema for product features.
## Candidate Source
### Official F1 Static Archived Timing Files
Reference:
https://livef1.goktugocal.com/livetimingf1/data_topics.html
Examples in public references include:
- `SessionInfo.json`
- `ArchiveStatus.json`
- `TrackStatus.jsonStream`
- `SessionData.json`
- `TyreStintSeries.json`
- `SessionStatus.json`
- `TimingDataF1.json`
Potential strengths:
- Could provide replay-quality archived live timing.
- May fill gaps between OpenF1 REST data and SignalR live data.
- May support historical race reconstruction.
Known uncertainties:
- Session path mapping must be researched.
- Stability and access guarantees are unclear.
- Topic schemas and file availability may vary by year/session.
- Legal and operational usage expectations need review.
Policy for now:
- Do not make core architecture depend on this source yet.
- Assign a dedicated research track to validate feasibility.
- If adopted, ingest it through the same raw-plus-normalized source pipeline.
## Source Authority Tiers
1. Local SQLite domain database.
- Primary read source for Web UI historical and completed-session data.
2. Official F1 SignalR live feed.
- Primary source during active sessions.
3. OpenF1 REST ingestion/backfill.
- Primary source for populating local historical data.
4. Optional F1 static archive source.
- Research candidate for richer replay and archived live timing.
5. Raw HTTP cache fallback.
- Last-resort resilience layer, not a product data model.
## Open Questions
- Should SignalR snapshots/events be persisted during live sessions?
- If persisted, should live data become the authoritative record for that
session or a supplemental event stream?
- Which OpenF1 endpoints are essential for v1 local-first Race Hub?
- Can static archived timing files be mapped reliably from OpenF1 sessions?
- What data should be refreshed after a session ends, and when should it become
immutable?

View File

@@ -0,0 +1,216 @@
# Backend Architecture
## Summary
The backend should move from direct page handlers calling OpenF1 into a layered
local-first architecture. Source clients fetch data, ingestion persists it,
store/query packages expose domain reads, and Web handlers return read models
with source and freshness metadata.
## Proposed Package Boundaries
### `internal/store`
Owns SQLite as the local domain database.
Responsibilities:
- Schema creation and migrations.
- Typed upsert methods for domain records.
- Typed read methods for screens and backend services.
- Raw payload storage.
- Ingestion metadata and provenance.
- Transactions and batch writes.
Non-goals:
- Calling OpenF1 directly.
- Knowing Web UI route behavior.
- Rendering derived frontend-specific structures unless they are shared read
models.
### `internal/ingest`
Coordinates backfill, refresh, and opportunistic fetches.
Responsibilities:
- Ingest year, meeting, or session.
- Fetch required endpoints through source clients.
- Persist raw payloads and normalized rows.
- Track partial successes and failures.
- Support resumable, idempotent runs.
- Respect rate limits and free-tier constraints.
Default ingestion modes:
- CLI bulk ingestion for years, meetings, and sessions.
- Opportunistic small fetches in Web mode when a user opens missing data.
- Explicit refresh mode for completed data when needed.
Rate-limit defaults:
- Bulk ingestion must be resumable and idempotent.
- Bulk ingestion should default to conservative sequential fetching with a
delay between OpenF1 requests.
- Failed requests should use bounded exponential backoff with jitter.
- HTTP 429 and live-session lockout should pause or stop the current run rather
than tight-loop retries.
- `--dry-run` should show planned datasets and estimated request count before a
large ingest.
### OpenF1 Source Client Layer
The current `internal/api` client can remain, but it should become one source
adapter rather than the main application data layer.
Responsibilities:
- Build OpenF1 URLs.
- Apply auth headers when `OPENF1_API_KEY` exists.
- Decode source payloads into source/domain structs.
- Preserve stale fallback behavior where useful.
Future direction:
- Make source fetches observable by ingestion metadata.
- Avoid direct UI route dependency on source calls.
### Live Timing Bridge
The current live parser should be extracted out of `internal/ui` into reusable
backend/domain logic.
Responsibilities:
- Connect to official F1 SignalR.
- Parse topic payloads into typed live events/state.
- Maintain current live snapshot.
- Broadcast snapshots to Web clients through SSE.
- Feed TUI live mode without coupling parser code to Bubble Tea.
- Persist live events/snapshots as an append-only stream once the bridge is
extracted.
Persistence policy:
- Live SignalR data should be stored separately from normalized post-session
OpenF1 records.
- Live data represents what was broadcast at the time, not necessarily the
corrected final historical record.
- A later reconciliation step can compare live stream data with OpenF1
post-session records.
### Web API Read Models
Web handlers should become thin adapters from query services to JSON.
Responsibilities:
- Validate route/query parameters.
- Call local-first query/read services.
- Return consistent response envelopes.
- Include source/freshness metadata.
Suggested response metadata:
- `source`: `local`, `api`, `cache`, `live`, or `missing`.
- `last_ingested_at`.
- `is_stale`.
- `missing_datasets`.
- `errors` where partial data is returned.
### CLI Ingestion Commands
CLI commands should make bulk ingestion explicit and user-controlled.
Candidate commands/flags:
- `--ingest-year 2024`
- `--ingest-meeting <meeting_key>`
- `--ingest-session <session_key>`
- `--refresh`
- `--dry-run`
CLI output should include:
- What will be fetched.
- What is already local.
- What succeeded.
- What failed.
- Whether the run is resumable.
## Local-First Read Behavior
Default rule:
1. Read from local domain DB.
2. If missing and request scope is small, optionally fetch from OpenF1.
3. Persist successful fetches.
4. Return local/read-model data with metadata.
5. If OpenF1 is unavailable, return partial local data and clear missing/stale
metadata rather than an empty page.
Examples:
- Opening a completed race with all local data should perform no OpenF1 calls.
- Opening a completed race with missing weather may opportunistically fetch only
weather.
- Opening a whole season should not silently trigger a large backfill.
- During live-session lockout, historical pages should still render from local
data.
## Opportunistic Fetch Policy
Allowed by default:
- Single meeting sessions.
- Single session results/grid/weather/race control.
- Small metadata gaps needed to render a screen.
Not allowed by default:
- Full season backfills.
- High-volume telemetry/location/car data.
- Repeated refresh loops during API lockout.
- Silent destructive refresh of completed local data.
## Migration Strategy
The existing SQLite HTTP cache should remain operational during the refactor.
The new domain database should be introduced without requiring users to delete
their current cache.
Default migration stance:
- Keep the current cache tables and stale fallback behavior intact.
- Introduce domain tables through `internal/store`.
- Prefer a separate domain database file at first if it materially reduces
migration risk; using the same SQLite file remains acceptable if table names
and migrations are carefully isolated.
- Do not attempt to transform arbitrary URL-keyed cache entries into domain rows
automatically.
- New ingestion runs should populate domain tables from fresh source fetches or
explicitly supported raw payloads.
- Web routes can migrate endpoint by endpoint from source-first to local-first.
## Failure Modes
The backend should explicitly represent:
- Local data available.
- Local data partial.
- Local data missing.
- OpenF1 locked/unavailable.
- Stale cache fallback used.
- Live feed connected/disconnected.
- Ingestion partial failure.
The Web UI should be able to show these states without guesswork.
## Open Questions
- Should API response envelopes be introduced globally or per endpoint during
migration?
- How should source schema drift be detected and surfaced?
- What is the minimum dataset required for a Race Hub to be considered
complete?

View File

@@ -0,0 +1,195 @@
# Database Design
## Summary
SQLite should become the local source of truth for historical and completed
session data. The design should store both raw source payloads and normalized
domain rows. Raw payloads preserve source fidelity and make reprocessing
possible. Normalized rows power fast product queries, analytics, and stable Web
screens.
## Storage Strategy
Use two layers:
1. Raw source storage.
- Preserve fetched payloads exactly enough to reprocess later.
- Track source, endpoint/topic, parameters, fetch time, status, and errors.
2. Normalized domain tables.
- Queryable application data keyed by F1 identifiers.
- Built from successful source payloads.
- Safe to upsert idempotently.
## Raw Payload Tables
Candidate tables:
- `source_payloads`
- `id`
- `source`
- `resource`
- `request_key`
- `url_or_topic`
- `params_json`
- `payload_json`
- `fetched_at`
- `status`
- `error`
- `schema_version`
- `ingestion_runs`
- `id`
- `scope_type`
- `scope_key`
- `started_at`
- `finished_at`
- `status`
- `refresh`
- `summary_json`
- `ingestion_items`
- `id`
- `run_id`
- `dataset`
- `meeting_key`
- `session_key`
- `status`
- `source`
- `started_at`
- `finished_at`
- `error`
## Normalized Domain Tables
Core calendar/session tables:
- `meetings`
- `sessions`
- `circuits`
Participant tables:
- `drivers`
- `session_drivers`
- `teams` or team snapshots by season/session.
Classification and standings:
- `session_results`
- `starting_grids`
- `driver_championship_standings`
- `constructor_championship_standings`
Race/session analysis:
- `laps`
- `stints`
- `pit_stops`
- `positions`
- `intervals`
- `race_control_messages`
- `weather_samples`
- `overtakes`
Telemetry and spatial data:
- `car_data_samples`
- `location_samples`
- `track_outlines`
Media metadata:
- `team_radio_messages`
Derived/read-model candidates:
- `session_dataset_status`
- `race_key_moments`
- `driver_session_summaries`
- `race_lap_snapshots`
Derived tables should be added only when query cost or UI complexity justifies
them. Start with normalized source tables and build read models in Go unless
performance argues otherwise.
## Provenance and Freshness
Each normalized dataset should be traceable to source ingestion metadata.
Track:
- Source: OpenF1, SignalR, static archive, manual, cache.
- First ingested time.
- Last ingested time.
- Last successful refresh.
- Last error.
- Completion status.
- Whether stale fallback was used.
This metadata supports the Data Library screen and makes partial data honest.
## Immutability Policy
Completed historical sessions:
- Treat as immutable after successful ingestion.
- Do not refetch unless `--refresh` is explicitly requested.
- Allow reprocessing from raw payloads if schema or read models change.
Current/future sessions:
- Treat as refreshable.
- Allow opportunistic metadata fetches.
- Avoid high-volume refreshes without explicit action.
Live sessions:
- SignalR is authoritative for live state.
- Persist live data as an append-only event/snapshot stream after the live
bridge is extracted.
- Keep live data separate from normalized post-session OpenF1 records until
reconciliation is designed.
- Treat live data as the record of what was seen during the session, not as the
corrected final historical truth.
## Migration And File Layout
The current project already creates a SQLite cache database for raw HTTP
responses. The domain database should be introduced without breaking that cache.
Default stance:
- Existing cache tables are infrastructure, not product domain state.
- New domain tables should be owned by `internal/store`.
- A separate domain DB file is the lower-risk first implementation unless a
schema design pass shows strong reasons to reuse the same file.
- If the same file is reused, domain tables must be namespaced clearly and
migrations must avoid touching the current `cache` table except through
deliberate cache work.
- Do not auto-migrate URL-keyed cache entries into domain rows.
- Use explicit ingestion to populate the new domain tables.
## High-Volume Data
High-volume tables need careful indexing and retention decisions:
- `car_data_samples`
- `location_samples`
- `positions`
- `intervals`
- `laps` for full-season analysis
Initial policy:
- Ingest high-volume telemetry only when explicitly requested.
- Keep Race Hub v1 focused on results, strategy, laps, race control, weather,
positions, and track outlines.
## Research Questions
- Exact indexes for Race Hub, Live Replay, Driver Explorer, and Standings.
- Whether `positions` and `intervals` should be downsampled or stored in full.
- Whether `car_data_samples` and `location_samples` should be optional datasets.
- How to map official F1 static archive sessions to OpenF1 `session_key`.
- Whether to use SQLite FTS for race-control/team-radio search.
- How to version schema migrations without adding unnecessary framework weight.

View File

@@ -0,0 +1,231 @@
# Web UI Product
## Summary
The Web UI should become the primary way to use `box-box`. The product should
feel like an F1 operations room: fast, dense when needed, precise, and native to
race-weekend workflows. It should work well on phone and iPad, while still
scaling into a richer desktop dashboard.
## Product Priorities
- Race-weekend first.
- Live Timing and Race Hub receive the highest polish.
- Historical pages should be local-first and reliable.
- Data availability should be visible, not mysterious.
- Density should be configurable.
- TUI live mode remains supported but does not require Web feature parity.
## Core Screens
### Command Center
Default landing screen.
Shows:
- Current or upcoming race weekend.
- Next session countdown.
- Live session state.
- Weekend schedule.
- Weather snapshot.
- Championship context.
- Local data availability.
- Shortcuts into Live Timing, Weekend, Race Hub, Standings, and Data Library.
### Season Calendar
Year-based browsing screen.
Shows:
- All meetings for the selected year.
- Round, country, circuit, date range.
- Upcoming/live/completed state.
- Local ingestion status.
- Key outcomes after completion: winner, pole, fastest lap where available.
- Filters for missing data, completed races, sprint weekends, and upcoming
rounds.
### Weekend Page
One workspace per Grand Prix weekend.
Shows:
- Meeting metadata.
- Circuit and location.
- Session cards.
- Schedule and status.
- Dataset completeness.
- Entry points into each session view.
### Race / Session Hub
Main historical analysis workspace.
For races, prioritize the strategy story:
- Final classification.
- Starting grid and grid delta.
- Stint chart with compounds and pit stops.
- Safety car and VSC overlays.
- Position evolution.
- Lap-time comparison.
- Race-control timeline.
- Weather timeline.
- Driver race execution summaries.
- Replay scrubber with lap-by-lap standings and events.
For practice and qualifying:
- Classification.
- Best laps and sector breakdown.
- Lap progression.
- Driver comparison.
- Session events and weather context.
### Live Timing
Primary active-session screen.
Shows:
- Timing tower.
- Session clock, lap count, and track status.
- Position, gap, interval, tyre, tyre age, pit state.
- Last lap, best lap, sector state, DRS/track status where available.
- Race-control messages.
- Battles.
- Pit window predictions.
- Pinned drivers.
- Visual in-app alerts.
### Live Track View
Initially a mode inside Live Timing.
Shows:
- Circuit outline.
- Live car positions.
- Team/driver coloring.
- Selected/pinned driver focus.
- Mini timing list.
- Track/flag context where available.
### Drivers
Driver explorer.
Shows:
- Current season driver list.
- Driver profile data.
- Team, number, and headshot where available.
- Season points and trend.
- Race-by-race result table.
- Teammate comparison.
- Tyre/stint tendencies.
- Live pinned-driver mode during active sessions.
### Standings
Championship context screen.
Shows:
- Driver standings.
- Constructor standings.
- Points gaps.
- Movement since previous race.
- Race-by-race points accumulation.
- What changed after a selected Grand Prix.
### Data Library
Local data transparency screen.
Shows:
- Seasons available locally.
- Weekend and session dataset completeness.
- Missing datasets.
- Last ingested timestamps.
- Source/staleness state.
- Suggested ingestion commands.
- API lockout and stale cache explanations.
### Settings
Local app preferences.
Shows:
- Density mode.
- Theme accents.
- Preferred season.
- Pinned drivers.
- API key status.
- Data/cache path.
- Live alert preferences.
## Navigation Model
Primary flow:
```text
Season -> Weekend -> Session / Race Hub
```
Live shortcut:
```text
Command Center -> Live Timing -> Track / Battles / Pit Window / Race Control
```
Data/support flow:
```text
Data Library -> ingestion status / missing data
```
Candidate routes:
- `/`
- `/season/:year`
- `/weekend/:meetingKey`
- `/session/:sessionKey`
- `/live`
- `/drivers`
- `/drivers/:driverNumber`
- `/standings/:year`
- `/data`
- `/settings`
## Responsive Expectations
Phone:
- Stacked panels.
- Sticky session/status header.
- Bottom navigation.
- Swipeable live panels.
- Compact timing rows.
iPad:
- Split-pane layout.
- Timing plus side panel.
- Touch-friendly controls.
- Comfortable chart inspection.
Desktop:
- Dense multi-column operations layout.
- Persistent side panels.
- More simultaneous context.
Density modes should influence row height, visible columns, chart spacing, and
panel compactness.

View File

@@ -0,0 +1,152 @@
# Frontend Stack
## Summary
The next Web UI should move from embedded Alpine/static assets to a real React
application. The target is a local-first, data-heavy, live-updating race
dashboard served by the Go backend.
## Current Web UI
Current stack:
- Go `net/http` server.
- Go `embed.FS` static assets.
- Plain HTML/CSS/JavaScript.
- Alpine.js from CDN.
- D3.js from CDN.
- Hash routing.
- Raw `fetch`.
- `EventSource` for live SSE.
- No frontend build system.
- No TypeScript.
- No package-managed frontend dependencies.
This is a good prototype shape but not a good long-term foundation for the
planned Web UI.
## Recommended Stack
### Vite
Purpose:
- Frontend dev server.
- Fast TypeScript build.
- Production asset bundling.
- Clean integration with Go embedded static assets.
### React
Purpose:
- Component model for complex screens.
- Good fit for live timing, charts, tables, filters, replay controls, and
persistent interaction state.
### TypeScript
Purpose:
- Stronger contracts for OpenF1, local API, and live timing payloads.
- Safer refactors.
- Better developer experience across data-heavy UI.
### TanStack Query
Purpose:
- Server-state fetching and caching.
- Loading/error/stale states.
- Background refresh.
- Clear handling of local DB data, API fallback, and partial data.
### Router
Preferred candidates:
- TanStack Router for stronger type safety.
- React Router if simplicity and familiarity matter more.
Routes should model product workflows rather than mimic current hash routing.
### D3
Purpose:
- Bespoke F1 visuals:
- Strategy charts.
- Track maps.
- Position evolution.
- Lap-time comparison.
- Gap history.
- Telemetry traces.
D3 should be used where the visual is genuinely custom. Simpler chart libraries
can be considered later for generic charts.
### Zustand
Optional.
Purpose:
- Local UI preferences and cross-screen client state:
- Pinned drivers.
- Density mode.
- Selected comparison drivers.
- Visible live panels.
- Replay speed.
Avoid adding it until React state and URL state become awkward.
### Testing
Vitest:
- Formatting helpers.
- Data transforms.
- Race calculations.
- Chart input shaping.
Playwright:
- Page routing.
- Race Hub rendering.
- Live SSE behavior with mocked events.
- Responsive layouts.
- Data Library states.
## Why Not Astro As The App Shell
Astro is excellent when pages are mostly static and only specific islands need
JavaScript. `box-box` is primarily an interactive application:
- Live timing updates.
- SSE streams.
- Dense tables.
- Replay scrubbers.
- Driver pinning.
- Interactive charts.
- Local-first data states.
Astro could wrap React islands, but most important screens would become React
islands anyway. That adds split architecture without much benefit for this app.
Astro may still be useful for:
- Public docs.
- A marketing/project site.
- Static release notes.
For the product UI, Vite + React + TypeScript is the cleaner fit.
## Build Integration
Target behavior:
- During frontend development, Vite serves the React app.
- During normal `go run cmd/main.go --web`, Go serves compiled frontend assets.
- The backend remains responsible for SQLite, ingestion, OpenF1, SignalR, REST,
and SSE.

View File

@@ -0,0 +1,123 @@
# Visual Design Direction
## Summary
The visual direction should be F1-native without falling into generic dashboard
habits. The app should feel like an operations room for following a race
weekend: technical, fast, sharp, and legible. It should avoid AI-slop patterns
such as endless decorative cards, vague gradient panels, giant generic hero
sections, and meaningless visual chrome.
## Design North Star
Use the phrase "F1 Ops Room" as the working direction.
Qualities:
- Dense but controlled.
- High signal.
- Fast to scan.
- Precise typography.
- Strong hierarchy.
- Team color used as information, not decoration.
- Good on phone and iPad, not just desktop.
## Density
Density should be configurable:
- Compact: timing-wall mode, maximum data per viewport.
- Comfortable: default mode for most users.
- Touch: larger hit targets and panel spacing for phone/iPad.
Density affects:
- Table row height.
- Visible columns.
- Panel spacing.
- Chart label detail.
- Header size.
- Control grouping.
## Timing-Wall Ergonomics
Live timing should prioritize scan speed:
- Position and driver identity must be easy to locate.
- Gap/interval changes should be visually distinct.
- Pit state, retired state, and tyre state should be obvious.
- Race-control alerts should interrupt without becoming noisy.
- Pinned drivers should remain available across live views.
## Team Color Discipline
Team colors are useful data, but they can quickly become visual noise.
Rules:
- Use team color for identity markers, row accents, chart lines, and selected
driver focus.
- Avoid flooding large surfaces with saturated team color.
- Always preserve contrast and legibility.
- Avoid making the whole interface a rainbow unless the context is explicitly
comparative.
## Layout Principles
Prefer:
- Full-width information bands.
- Dense tables with strong alignment.
- Split panes.
- Sticky session headers.
- Bottom navigation on phone.
- Clear panel switching on smaller screens.
- Charts that explain race state, not just decorate.
Avoid:
- Card sludge: every concept boxed into a decorative card.
- Floating cards inside cards.
- Generic SaaS dashboard grids.
- Purple/blue gradient panels with no product meaning.
- Decorative orbs, bokeh, or random glow effects.
- Vague hero sections.
- Overly large typography inside operational surfaces.
## F1-Native References To Research
Research should study:
- Official F1 timing tower ergonomics.
- Broadcast graphics hierarchy.
- FIA timing/result sheet density.
- Race control message formatting.
- Pit wall and telemetry workstation patterns.
- Motorsport data overlays.
The goal is not to copy official F1 branding. The goal is to understand the
information hierarchy and pacing of motorsport interfaces.
## Mobile And iPad
The app should work well on phone and iPad because those are likely primary
second-screen devices during race sessions.
Phone:
- Prioritize Live Timing, alerts, pinned drivers, and quick switching.
- Use stacked panels and sticky status.
- Keep interactions thumb-friendly.
iPad:
- Use two-pane and three-pane layouts.
- Keep charts inspectable.
- Make side panels easy to swap.
Desktop:
- Allow dense multi-panel layouts.
- Show more simultaneous context.
- Preserve keyboard and pointer efficiency.

View File

@@ -0,0 +1,206 @@
# Research Agents Brief
## Summary
Before implementation tickets are written, dedicated research agents should
investigate the uncertain parts of the refactor. Their outputs should feed a
product/architecture planning pass that turns findings into phased work.
Each research track should separate confirmed facts, assumptions, risks, and
recommendations.
## 1. OpenF1 Contract Research
Objective:
- Document the exact OpenF1 endpoint contract needed by `box-box`.
Inputs:
- Existing `internal/api` client.
- OpenF1 docs: https://openf1.org/docs/
- Current app screens and planned Race Hub requirements.
Outputs:
- Endpoint inventory.
- Field/schema notes.
- Update cadence by endpoint.
- Auth/free-tier behavior.
- Rate-limit and lockout notes.
- Essential vs optional datasets for v1.
Key questions:
- Which endpoints are immutable after session completion?
- Which endpoints are high-volume enough to require explicit ingestion?
- What errors are returned during live-session lockout?
- Which endpoints can be filtered to reduce ingestion cost?
## 2. Official F1 Live Timing Research
Objective:
- Document the SignalR live feed contract and parser risks.
Inputs:
- Current `internal/ui/official_live.go`.
- SignalR endpoint: https://livetiming.formula1.com/signalr
- OpenF1.Data package notes:
https://www.nuget.org/packages/OpenF1.Data/1.0.87
Outputs:
- Topic inventory.
- Payload examples where available.
- Parser fragility notes.
- Recommended domain event/state model.
- Testing strategy for non-live periods.
Key questions:
- Are current subscribed topics sufficient for the planned Web live mode?
- Which topics should be parsed as events vs current state?
- How should disconnections and reconnections be represented?
- Should live snapshots/events be persisted?
## 3. Static Archive Feasibility Research
Objective:
- Determine whether official F1 static archived timing files should become a
supported source.
Inputs:
- LiveF1 data topic reference:
https://livef1.goktugocal.com/livetimingf1/data_topics.html
- Public static archive URL patterns.
- OpenF1 meeting/session metadata.
Outputs:
- Feasibility assessment.
- Session path mapping strategy.
- Available years/session types.
- Topic/file inventory.
- Risks and legal/operational considerations.
Key questions:
- Can OpenF1 sessions be mapped reliably to static archive paths?
- Are static archive files available consistently?
- Which files provide replay-quality timing?
- Is this source stable enough for v1 or later only?
## 4. SQLite Schema And Indexing Design
Objective:
- Turn the domain database design into a concrete schema proposal.
Inputs:
- `03-database-design.md`.
- Existing `internal/models/types.go`.
- Race Hub and Live Replay query requirements.
Outputs:
- Table definitions.
- Primary keys and foreign keys.
- Index proposal.
- Raw payload strategy.
- Migration strategy.
- High-volume data retention recommendations.
Design questions:
- Which tables need composite primary keys?
- Which read paths need covering indexes?
- Should telemetry/location be optional datasets?
- Should derived read-model tables exist in v1?
## 5. Backend API And Read-Model Design
Objective:
- Design the Web API shape that React will consume.
Inputs:
- Existing `internal/web/api.go`.
- Planned Web screens.
- Store/query requirements.
Outputs:
- Endpoint proposal.
- Response envelope proposal.
- Source/staleness metadata shape.
- Error/partial-data behavior.
- Migration strategy from existing endpoints.
Design questions:
- Should existing `/api/v1` routes be preserved and expanded?
- What metadata should every response include?
- How should partial data be represented?
- Which read models should be backend-computed vs frontend-computed?
## 6. F1-Native Visual System Research
Objective:
- Produce visual principles and examples for the React UI before components are
built.
Inputs:
- `06-visual-design-direction.md`.
- F1 broadcast timing graphics.
- FIA timing/result sheets.
- Motorsport telemetry and timing tools.
Outputs:
- Moodboard or written reference guide.
- Layout principles.
- Typography and density guidance.
- Color usage rules.
- Anti-pattern list.
Key questions:
- How should the app look F1-native without copying official branding?
- What visual hierarchy makes live timing fastest to scan?
- How should phone/iPad layouts differ from desktop?
- How can the UI avoid generic card-heavy dashboard design?
## 7. Testing Strategy Research
Objective:
- Define a test strategy for backend, ingestion, frontend, and live behavior.
Inputs:
- Existing tests.
- Planned store/ingestion architecture.
- Live feed limitations outside active sessions.
Outputs:
- Backend unit/integration test plan.
- Ingestion fixture strategy.
- Frontend Vitest and Playwright strategy.
- Mock SSE/live fixture plan.
- Manual acceptance checklist.
Key questions:
- How should live SignalR behavior be tested without an active session?
- What source payload fixtures are needed?
- Which scenarios require real OpenF1 integration tests?
- How should local DB migrations be tested?

View File

@@ -0,0 +1,186 @@
# 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
original implementation brief.
### 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
original implementation brief.
### 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 original implementation brief.
### 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 original implementation brief.
### 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.
- This is the first frontend phase. Use Claude for this phase.
- See [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md) for the
original implementation brief.
## 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.
### Phase 6: React Race Hub Analytics
- Add Race Hub tabs or segmented views.
- Keep classification and grid intact.
- Add Dataset Status, Strategy, and Position Evolution views.
- Use real local-first data where available and honest missing states otherwise.
- Continue frontend work with Claude.
### Phase 7: Analytics Data Foundation
- Return to Cursor for backend work.
- Add local-first store, ingestion, and Race Hub API support for stints,
positions, and related analytics datasets.
- Keep React Strategy/Position views honest until real data is available.

View File

@@ -0,0 +1,195 @@
# Phase 1 Live Extraction
## Purpose
Phase 1 creates a stable live timing foundation without changing the product
surface. The current live mode is the strongest part of `box-box`, but the core
SignalR connection and parsing code lives inside `internal/ui`. That creates a
bad dependency direction: the Web server imports TUI code only to access live
data types and `ConnectToF1LiveTiming`.
The goal is to extract the reusable live timing core into `internal/live`, keep
the TUI and Web UI working, and add fixture-based tests around the parsing
surface. This is a foundation phase, not a frontend redesign phase.
## Manager Decision
I agree with Claude that Race Hub is the safest first React product slice.
However, before React work starts, the live timing backend should be separated
from the TUI. The current Web UI already depends on live data through SSE, and
future React live screens will need that source without importing terminal UI
code.
Therefore Phase 1 is:
- Extract the live SignalR bridge into `internal/live`.
- Update TUI live mode to consume `internal/live`.
- Update Web SSE live mode to consume `internal/live`.
- Add tests for live message parsing/state updates.
- Do not add persistence, React, or new UI behavior yet.
## Current Coupling To Remove
Current state:
- `internal/ui/official_live.go` owns SignalR protocol types, live data types,
topic parsing, connection setup, and TUI rendering.
- `internal/web/live.go` imports `internal/ui` for `ui.LiveStreamData` and
`ui.ConnectToF1LiveTiming`.
Target state:
- `internal/live` owns reusable live data structures, SignalR protocol parsing,
connection setup, and state update logic.
- `internal/ui` owns Bubble Tea model state, keyboard behavior, and terminal
rendering.
- `internal/web` owns SSE clients, HTTP handlers, reconnect/backoff policy, and
JSON responses.
## Proposed Package Boundary
Add:
```text
internal/live/
types.go LiveStreamData, LiveDriverData, weather, race control, tyres
signalr.go negotiate/connect/subscribe to official F1 SignalR
parser.go raw message parsing and topic dispatch
state.go mutable live state accumulator and snapshot copying
parser_test.go fixture-driven tests
testdata/ small captured/synthetic SignalR messages
```
The exact file split can change during implementation, but the boundary should
stay clear: `internal/live` must not import `internal/ui` or Bubble Tea.
## API Shape
Keep a small API compatible with current callers:
```go
package live
type StreamData = LiveStreamData // or a normal exported type if clearer
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error
```
Optional improvements are allowed only if they stay small and do not force broad
behavior changes:
```go
type Client struct {
// future room for custom http client, logger, topic list, clock, etc.
}
func (c *Client) Connect(dataChan chan LiveStreamData) error
```
If a `Client` is introduced, preserve the top-level
`ConnectToF1LiveTiming(dataChan)` as a convenience wrapper so TUI and Web changes
remain boring.
## What Moves From `internal/ui/official_live.go`
Move or duplicate-then-delete these reusable concerns into `internal/live`:
- `F1SignalRMessage`
- `F1TimingLine`
- `F1DriverListEntry`
- `LiveTyreData`
- `LiveRCMessage`
- `LiveWeatherData`
- `LiveSessionMeta`
- `LiveSectorData`
- `LiveDriverData`
- `LiveStintData`
- `LiveStreamData`
- `ConnectToF1LiveTiming`
- topic parsing and state accumulation helpers currently embedded in the
connection goroutine
- snapshot-copying logic used before sending updates
Keep these TUI-specific concerns in `internal/ui/official_live.go`:
- `OfficialLiveModel`
- Bubble Tea messages and commands
- viewport handling
- keybindings
- terminal render functions
- battle/pit-window display logic unless it is already pure and clearly useful
to share
## Tests
Live sessions are not always available, so Phase 1 tests must not depend on a
current race weekend. Add fixture-based tests in `internal/live`.
Minimum test coverage:
- Parse a SignalR `R` full-state message.
- Parse a SignalR `M` incremental update message.
- Handle known topics without panicking:
- `TimingData`
- `DriverList`
- `LapCount`
- `ExtrapolatedClock`
- `TrackStatus`
- `RaceControlMessages`
- `WeatherData`
- `SessionInfo`
- `CurrentTyres`
- `TimingAppData`
- `TimingStats`
- Preserve existing string/float/nested-value handling in timing fields.
- Ignore unknown topics without failing.
- Verify snapshots copy maps/slices so downstream consumers cannot mutate
internal accumulator state accidentally.
Fixtures can be small synthetic messages shaped like the official feed. They do
not need to be full captured race payloads.
## Acceptance Criteria
Phase 1 is complete when:
- `internal/web/live.go` no longer imports `internal/ui`.
- `internal/ui/official_live.go` compiles while consuming `internal/live`.
- The existing TUI live mode still uses the official F1 SignalR feed.
- The existing Web live SSE path still uses the official F1 SignalR feed.
- `go test ./...` passes.
- Parser tests run without internet access.
- No local database, React, or visual redesign work has been started as part of
this phase.
## Non-Goals
Do not include these in Phase 1:
- React/Vite frontend setup.
- SQLite domain database or migrations.
- OpenF1 ingestion refactor.
- Live event persistence.
- Race Hub implementation.
- Static archive research.
- Browser notification work.
- Major rewrite of TUI live rendering.
## Risks And Guardrails
- The live parser currently works in practice; avoid clever rewrites that change
behavior without tests.
- Official F1 SignalR topic schemas can drift. Keep parsing tolerant of missing,
empty, string, numeric, and nested values.
- Do not make Web reconnect/backoff policy part of `internal/live` yet. The Web
server can keep owning that operational behavior.
- Do not make the TUI import Web code. Shared logic should flow through
`internal/live`.
- Preserve existing logs and user-facing behavior unless a small compile-time
adjustment requires otherwise.
## Next Phase After This
After Phase 1, Phase 2 should start `internal/store` and the local SQLite domain
database. Live persistence should still wait until the live data/event types have
settled and the database provenance design is ready.

View File

@@ -0,0 +1,136 @@
# Phase 2 Store Foundation
## Purpose
Phase 2 introduces the local domain database foundation. The current SQLite
database is an HTTP response cache. That should remain intact, but it is not the
same thing as an app-owned F1 domain store.
The goal of this phase is to add `internal/store` with schema initialization,
migrations, provenance-aware raw payload storage, and a small set of typed
domain tables needed by Race Hub v1. This phase should not build ingestion
commands or change the Web UI yet.
## Manager Decision
Keep this phase boring and structural. Do not try to ingest a full weekend yet.
The deliverable is a tested store package that later phases can call.
Phase 2 should prove:
- the app can create/open a domain SQLite database;
- migrations are repeatable and idempotent;
- raw source payloads can be stored with provenance;
- basic meeting/session/driver/session result records can be upserted and read;
- existing HTTP cache behavior is untouched.
## Package Boundary
Add:
```text
internal/store/
db.go open/close database, pragmas, transaction helper
migrations.go embedded SQL migrations and schema versioning
models.go store-layer structs for v1 domain records
raw.go raw payload/provenance writes and reads
meetings.go typed meeting/session upserts and reads
results.go typed driver/result/grid-style records as initial slice
store_test.go temp-db migration and CRUD tests
```
The exact file split can change, but `internal/store` should not import
`internal/ui` or `internal/web`.
## Database Location
Use a conservative default path separate from the existing HTTP cache:
```text
~/.local/share/box-box/boxbox.db
```
Tests must use temporary databases, not the user's real home directory.
## Initial Schema Scope
Create tables for:
- `schema_migrations`
- `raw_payloads`
- `ingestion_runs`
- `meetings`
- `sessions`
- `drivers`
- `session_drivers`
- `session_results`
- `starting_grid`
It is acceptable to include additional Race Hub v1 tables if doing so is
straightforward, but do not overbuild high-volume telemetry yet.
## Raw Payload Strategy
`raw_payloads` should preserve source truth before normalization.
Recommended columns:
- source name, such as `openf1`
- endpoint or topic
- request key or URL
- meeting key when known
- session key when known
- payload JSON text/blob
- payload hash
- fetched timestamp
- provenance metadata JSON
Raw payload storage should be idempotent by source/request/hash or another
clear uniqueness rule.
## Domain Table Strategy
Use stable OpenF1 identifiers where available:
- `meeting_key`
- `session_key`
- `driver_number`
Prefer explicit upserts over blind inserts. Completed historical data should be
safe to re-run without duplicating rows.
## Tests
Minimum tests:
- opening a temp database applies migrations;
- migrations can be run twice;
- schema version is recorded;
- raw payload insert/read works and preserves provenance;
- duplicate raw payload writes do not create accidental duplicates;
- meeting/session/driver/result upserts are idempotent;
- basic Race Hub read helpers can retrieve inserted meeting/session/result data.
## Non-Goals
Do not include these in Phase 2:
- OpenF1 backfill orchestration.
- CLI ingestion commands.
- Web UI changes.
- React setup.
- Replacing existing `internal/api/cache.go`.
- High-volume telemetry tables for car data/location.
- Live SignalR persistence.
## Acceptance Criteria
Phase 2 is complete when:
- `internal/store` exists with tested migration and CRUD behavior.
- The package can create a fresh SQLite domain database.
- Running migrations repeatedly is safe.
- Store tests pass without internet access.
- `go test ./internal/store/...` passes.
- `go test ./...` either passes or only fails because existing OpenF1
integration tests cannot reach the network/API.

View File

@@ -0,0 +1,163 @@
# Phase 3 Ingestion Foundation
## Purpose
Phase 3 connects OpenF1 REST data to the local domain store introduced in Phase
2. The goal is to ingest a meeting or session intentionally, record provenance,
write raw payloads, normalize the initial Race Hub datasets, and make the work
idempotent and resumable.
This phase should still avoid Web UI replacement work. It creates the backend
path that later Race Hub APIs and React screens can trust.
## Manager Decision
Build ingestion as an explicit backend workflow first, not as an automatic Web
side effect. Normal browsing must not accidentally trigger a full weekend
backfill or burn through API quota.
Phase 3 should add:
- `internal/ingest` orchestration.
- OpenF1 source-to-store mapping for the Phase 2 tables.
- A small CLI command path for manual ingestion.
- Dry-run and progress output.
- conservative retry/rate-limit behavior.
## Package Boundary
Add:
```text
internal/ingest/
ingest.go orchestrator, options, result summary
openf1.go OpenF1 source adapter and model mapping
progress.go progress event/output helpers if useful
ingest_test.go fake-source/fake-store or temp-db tests
```
The package should depend on:
- `internal/api` for OpenF1 reads;
- `internal/store` for writes;
- `internal/models` for current OpenF1 response structs.
It should not depend on:
- `internal/ui`;
- `internal/web`;
- React/frontend code.
## Initial Ingestion Scope
Support these commands/workflows first:
- ingest meetings for a year;
- ingest sessions for a meeting;
- ingest a single session's Race Hub v1 datasets.
For a race session, ingest:
- meeting metadata when available;
- session metadata;
- drivers;
- session result;
- starting grid;
- raw payload records for each fetched endpoint.
If Cursor chooses to include laps, stints, pits, race control, or weather, the
store schema must support them first. Otherwise leave those datasets for Phase
4 or a Phase 3 follow-up. Do not jam JSON blobs into unrelated tables just to
claim coverage.
## CLI Shape
Extend `cmd/main.go` conservatively. Keep the default TUI and `--web` behavior
unchanged.
Recommended flags:
```bash
go run cmd/main.go --ingest-year 2025
go run cmd/main.go --ingest-meeting 1229
go run cmd/main.go --ingest-session 9472
go run cmd/main.go --ingest-session 9472 --dry-run
go run cmd/main.go --ingest-session 9472 --db /path/to/boxbox.db
```
This is acceptable as a first CLI slice. A richer subcommand framework can wait.
## Ingestion Behavior
Defaults:
- sequential requests;
- small delay between endpoint calls;
- bounded retry for transient failures;
- stop cleanly on OpenF1 live-session lockout;
- no silent full-season backfills;
- print progress and final summary;
- write raw payload provenance for each endpoint;
- upsert normalized records so reruns are safe.
## Raw Payload Provenance
Each fetched endpoint should record:
- source: `openf1`;
- endpoint name;
- request key;
- meeting key when known;
- session key when known;
- fetched timestamp;
- raw JSON payload;
- HTTP/API provenance when available;
- whether data came from stale cache if that signal is available.
If the current API client does not expose raw JSON easily, prefer a small source
adapter enhancement over duplicating HTTP logic wildly. Keep existing cache
behavior intact.
## Tests
Tests should avoid real network calls.
Minimum tests:
- ingesting a fake session writes drivers, results, grid rows, and raw payloads;
- rerunning the same ingestion does not duplicate normalized rows;
- dry-run does not write domain rows;
- source errors stop the run and record/report failure;
- live-session lockout is surfaced as a controlled failure;
- CLI flag parsing does not break default TUI/Web behavior if covered cheaply.
## Non-Goals
Do not include these in Phase 3:
- React/Vite frontend implementation.
- Web Race Hub API replacement.
- automatic Web-triggered backfill.
- live SignalR persistence.
- full-season default backfill.
- static archive ingestion.
- high-volume car telemetry ingestion.
## Acceptance Criteria
Phase 3 is complete when:
- `internal/ingest` exists and is covered by offline tests.
- A user can manually ingest a year, meeting, or session from the CLI.
- Rerunning ingestion is idempotent.
- Raw payloads and normalized records are both written.
- `go test ./internal/ingest/... ./internal/store/...` passes.
- `go build -o /tmp/box-box ./cmd/main.go` passes.
- `go test ./...` either passes or only fails because existing OpenF1
integration tests cannot reach the network/API.
## Next Phase After This
Phase 4 should add local-first backend read models and Web API endpoints for
Race Hub v1. It should make the Web API prefer local SQLite data and report
missing datasets honestly.

View File

@@ -0,0 +1,139 @@
# Phase 4 Local-First Web API
## Purpose
Phase 4 makes the Web API start behaving like a local-first product. Phases 2
and 3 created the domain store and explicit ingestion path; this phase adds
read models that prefer local SQLite data and report data availability honestly.
This is still a backend phase. Do not start React yet.
## Manager Decision
Build one credible local-first Race Hub API slice before replacing the frontend.
The current Web UI can keep working from the existing endpoints, but the backend
should expose store-backed responses that a future React Race Hub can trust.
Phase 4 should add:
- store-backed read models for ingested meetings, sessions, drivers, results,
and grid;
- dataset/status metadata so the UI knows what is local, missing, or stale;
- optional small API fallbacks only when explicitly requested;
- tests for local-first behavior without network.
## Package Boundary
Prefer adding a backend read-model layer instead of embedding SQL inside HTTP
handlers.
Recommended shape:
```text
internal/query/
racehub.go Race Hub read model assembly
metadata.go dataset availability/source metadata
query_test.go temp-db tests
```
Then wire `internal/web` to use that layer.
If the implementation keeps the read layer inside `internal/web` temporarily,
it must still avoid duplicating store SQL across handlers.
## Initial API Scope
Add a new Race Hub endpoint:
```text
GET /api/v1/race-hub?session_key=9472
```
Response should include:
- meeting;
- session;
- drivers;
- session results enriched with driver/team fields;
- starting grid enriched with driver/team fields;
- dataset availability metadata.
Recommended metadata shape:
```json
{
"source": "local",
"session_key": 9472,
"datasets": {
"meeting": {"status": "available", "source": "local"},
"session": {"status": "available", "source": "local"},
"drivers": {"status": "available", "source": "local", "count": 20},
"results": {"status": "missing", "source": "none", "count": 0},
"starting_grid": {"status": "available", "source": "local", "count": 20}
}
}
```
Exact field names can vary, but the response must make missing datasets visible
instead of silently returning empty app states.
## Existing Endpoint Policy
Do not rewrite every existing endpoint yet. It is enough to:
- add the new local-first Race Hub endpoint;
- optionally make `/api/v1/meetings`, `/api/v1/sessions`, `/api/v1/drivers`,
`/api/v1/results`, and `/api/v1/grid` read from local data when present;
- preserve old OpenF1 behavior when local data is absent unless the request asks
for local-only behavior.
Recommended query controls:
```text
?source=local local only; no OpenF1 fallback
?source=auto local first, existing OpenF1 fallback when missing
```
Default should be conservative for existing endpoints. The new Race Hub endpoint
can default to local-first with honest missing metadata.
## Server Wiring
`web.Server` currently only receives `*api.OpenF1Client`. Add an optional
`*store.Store` or query service so Web mode can read the domain DB.
CLI/server behavior should remain simple:
```bash
go run cmd/main.go --web
go run cmd/main.go --web --db /path/to/boxbox.db
```
If the DB does not exist or has no ingested data, Web mode should still start.
## Non-Goals
Do not include these in Phase 4:
- React/Vite frontend setup.
- replacing the current static Web UI;
- automatic ingestion from Web browsing;
- live SignalR persistence;
- laps/stints/pits/weather/race-control read models unless the store schema is
expanded and tested first.
## Acceptance Criteria
Phase 4 is complete when:
- a local-first Race Hub endpoint exists;
- it can return ingested session data without OpenF1 calls;
- it reports missing datasets explicitly;
- Web mode can be pointed at a domain DB with `--db`;
- offline tests cover the read model and HTTP handler behavior;
- focused tests and build pass.
## Next Phase After This
Phase 5 is the first frontend implementation phase. That is the point to switch
from Cursor to Claude for React/UI work.

View File

@@ -0,0 +1,105 @@
# Phase 5 React Race Hub
## Purpose
Phase 5 begins the production Web UI. The backend now has the foundation needed
for a local-first Race Hub: live timing is shared, a domain store exists,
ingestion can populate it, and `/api/v1/race-hub` can read from local data with
dataset metadata.
This is the point to switch from Cursor to Claude for frontend/UI work.
## Manager Decision
Start with a focused React Race Hub slice, not a full app rewrite. The goal is
to prove the chosen frontend stack, visual language, responsive layout, and API
contract against the new local-first backend.
Keep the old Web UI available until the React route is credible.
## Scope
Add a Vite + React + TypeScript frontend foundation and build a first Race Hub
route around:
- meeting/session header;
- dataset/source status strip;
- classification table;
- starting grid table;
- driver/team color treatment;
- missing dataset states;
- compact Race Hub navigation shell;
- responsive desktop, tablet, and phone layouts.
Use `/api/v1/race-hub?session_key=...` as the primary API.
## Stack Defaults
- Vite
- React
- TypeScript
- TanStack Query
- TanStack Router, unless integration cost argues for React Router
- D3 only for bespoke charts later; do not use it for basic layout tables
- Vitest for component/unit tests
- Playwright for at least one smoke path if practical
## Visual Direction
The temporary static mockups used during early product exploration have been
removed now that the production React routes exist. Use the implemented React
screens as the current source of truth, and keep this visual direction in mind
for future refinement.
The UI should feel like an F1 operations room:
- dense but readable;
- technical, not generic SaaS;
- restrained use of panels;
- no card sludge;
- no decorative gradient blobs;
- strong timing-table ergonomics;
- team colors used as data, not wallpaper;
- mobile views designed directly, not merely squeezed desktop.
## Integration Policy
Do not rip out the existing static Web UI on day one. Add the React app in a way
that can coexist while the route is built and tested.
Acceptable approaches:
- add a Vite app under a dedicated frontend directory and document the dev flow;
- serve built assets from Go only after the React slice is stable;
- expose a `/react` or equivalent route temporarily if needed.
The implementation should avoid large backend changes except for tiny API
contract fixes discovered while integrating.
## Non-Goals
Do not include these in Phase 5:
- full replacement of every existing Web screen;
- live timing React rewrite;
- ingest UI;
- settings UI;
- full season/calendar rebuild;
- new backend ingestion features;
- persistence of live SignalR events.
## Acceptance Criteria
Phase 5 is complete when:
- the React app can run locally;
- a Race Hub screen loads from `/api/v1/race-hub`;
- available and missing datasets are visibly distinct;
- the layout is usable on desktop and phone widths;
- tests or smoke checks cover the Race Hub happy path;
- the old Web UI still works.
## Next Phase After This
Phase 6 should expand the React app around the Race Hub: strategy chart,
position evolution, lap comparison, and richer Data Library/status workflows.

View File

@@ -0,0 +1,79 @@
# Phase 6 React Race Hub Analytics
## Purpose
Phase 6 expands the React Race Hub from a classification/grid slice into a more
useful race analysis surface. Phase 5 proved the React stack, API contract,
desktop layout, and phone table behavior. This phase should add the first
bespoke F1 analysis views without replacing the whole Web app.
This remains a frontend-led phase for Claude.
## Scope
Add Race Hub tabs or segmented views for:
- Overview / Classification
- Starting Grid
- Strategy
- Position Evolution
- Dataset Status
Strategy and position views should be built from local-first backend data only
when the backend exposes the needed datasets. If laps/stints/positions are not
yet available through `/api/v1/race-hub`, add clear missing states instead of
fake charts.
## Backend Contract
Current Race Hub API:
```text
GET /api/v1/race-hub?session_key=...
```
Current datasets:
- meeting
- session
- drivers
- results
- starting_grid
If analytics require laps, stints, pit stops, or position samples, keep backend
changes small and explicit. Do not reintroduce direct OpenF1 reads from the
React app.
## Design Direction
Improve the information hierarchy without drifting into generic dashboard UI:
- stronger timing-wall readability;
- compact controls;
- minimal panel framing;
- no decorative gradients or card sludge;
- team colors as data accents;
- mobile views that fit the active columns rather than relying on horizontal
scrolling.
## Non-Goals
Do not include these in Phase 6:
- live timing React rewrite;
- full season calendar rebuild;
- settings UI;
- ingest UI;
- static archive support;
- replacing the old Go-served Web UI entirely.
## Acceptance Criteria
Phase 6 is complete when:
- Race Hub has an ergonomic tab/segmented-view structure;
- classification and grid remain intact;
- analytics views show either real local data or honest missing states;
- desktop and phone layouts have been visually checked;
- frontend tests/build pass;
- Go build still passes.

View File

@@ -0,0 +1,52 @@
# Phase 7 Analytics Data Foundation
## Purpose
Phase 7 returns to backend work. Phase 6 added honest frontend placeholders for
strategy and position evolution, but the Race Hub API does not yet expose the
local datasets needed to draw those views.
The goal is to expand the local store, ingestion, and Race Hub read model with
the first analytics datasets.
## Scope
Add local-first support for:
- laps;
- stints;
- pit stops;
- race control;
- weather;
- positions, if volume and schema stay manageable.
Prioritize stints and positions because they unlock the Strategy and Position
Evolution views.
## Backend Work
Expected changes:
- add SQLite tables and migrations for the selected datasets;
- add store upsert/read methods;
- extend `internal/ingest` session ingestion;
- extend `internal/query.RaceHub`;
- extend `/api/v1/race-hub` metadata counts;
- keep raw payload provenance for every fetched endpoint.
## Guardrails
- Keep ingestion idempotent.
- Keep tests offline.
- Do not fetch OpenF1 directly from React.
- Do not persist high-volume car telemetry yet.
- If positions are too large for this phase, document the limit and implement
stints/pits first.
## Acceptance Criteria
- Store migrations and CRUD tests pass.
- Ingestion writes new datasets and raw payloads.
- Race Hub API exposes new datasets with metadata.
- Existing React placeholders can detect available stints/positions.
- Focused Go tests pass.

View File

@@ -0,0 +1,65 @@
# Phase 8 Analytics Visuals
## Purpose
Phase 7 added the backend data foundation for Race Hub analytics: stints, pit
stops, positions, race control, weather, and laps now flow through the local
SQLite store, ingestion, query layer, and `/api/v1/race-hub`.
Phase 8 returns to frontend work. The goal is to replace the Strategy and
Position placeholder states with useful, production-minded views that consume
the real local-first analytics arrays now present in the Race Hub payload.
## Scope
Build the first real analytics views for:
- race strategy from stints and pit stops;
- position evolution from position samples;
- lightweight supporting context from race control, weather, and laps where it
improves the view without making the screen noisy.
The work should stay inside the React Race Hub surface. Do not redesign the
whole application shell in this phase.
## Frontend Work
Expected changes:
- pass `stints`, `pit_stops`, `positions`, `race_control`, `weather`, and `laps`
into the relevant Race Hub components;
- replace "chart not yet implemented" placeholders with real visual treatment;
- preserve honest missing-data states for sessions that only have core datasets;
- keep the design dense, technical, and F1-native;
- add focused component/unit tests for available and missing analytics data;
- update Playwright coverage so seeded analytics views prove the real data path
works.
## Visual Direction
Prefer timing-wall clarity over dashboard decoration:
- stint bars should be compact and scan-friendly;
- team colors should identify drivers without overpowering compound colors;
- compound colors should be disciplined and legible;
- position evolution should make gain/loss and driver comparison obvious;
- avoid decorative cards, giant empty panels, vague gradients, and generic SaaS
chart chrome.
## Guardrails
- Do not fetch OpenF1 directly from React.
- Do not add a heavy charting library unless the local interaction genuinely
needs it; SVG/CSS is enough for this first slice.
- Do not hide missing datasets behind fake mock data in runtime views.
- Keep mobile and iPad layouts usable, not just desktop-polished.
- Keep backend changes out of scope unless a clear API bug is discovered.
## Acceptance Criteria
- Strategy tab renders real stint/pit information when analytics data exists.
- Position tab renders real position information when position samples exist.
- Missing-data sessions still show clear unavailable states.
- Existing Race Hub views keep working.
- Frontend tests and build pass.
- Playwright Race Hub e2e passes against the seeded local database.

View File

@@ -0,0 +1,52 @@
# Phase 9 Navigation Data API
## Purpose
Race Hub now has useful local-first session views, but it still depends on a
manual `session_key`. Phase 9 should make the backend expose enough local
navigation data for the Web UI to become race-weekend-first: season calendar,
meeting detail, sessions, and ingestion coverage.
This is a backend/read-model slice for Cursor. Keep the React redesign for the
following phase.
## Scope
Add local-first Web API endpoints/read models for:
- seasons or available years in the domain database;
- meetings for a year;
- one meeting/weekend with its sessions;
- per-session dataset coverage using the same dataset vocabulary as Race Hub;
- a sensible "latest available" or "default session" helper if it can be done
without guessing from remote API data.
## Backend Work
Expected changes:
- add query-layer read models in `internal/query` for calendar/weekend data;
- add store reads if existing methods are insufficient;
- add HTTP handlers in `internal/web`;
- keep responses local-first and deterministic;
- expose empty but well-shaped responses when the database has no ingested
meetings;
- add offline tests using temporary SQLite databases.
## Guardrails
- Do not fetch OpenF1 from these read endpoints.
- Do not make React depend on OpenF1 directly.
- Do not start frontend navigation implementation in this phase.
- Keep endpoint names stable and boring; this is app infrastructure, not a
product copywriting exercise.
- Keep the existing Race Hub API working unchanged.
## Acceptance Criteria
- Web API can list ingested years and meetings.
- Web API can return a meeting/weekend with sessions.
- Each session includes dataset coverage needed to guide users into Race Hub.
- Empty database behavior is explicit and tested.
- Focused Go tests pass.
- Existing frontend unit/build/e2e checks still pass.

View File

@@ -0,0 +1,47 @@
# Phase 10 Navigation UI
## Purpose
Phase 9 added local-first navigation APIs for seasons, weekends, sessions, and
dataset coverage. Phase 10 should use those APIs in the React Web UI so users
can browse ingested data instead of manually typing a `session_key`.
This is a frontend slice. Keep it functional and restrained; full visual polish
can come after the navigation workflow exists.
## Scope
Add React UI for:
- available seasons from `/api/v1/seasons`;
- locally ingested meetings for a selected year;
- one weekend view from `/api/v1/weekend?meeting_key=...`;
- session selection that routes into existing Race Hub views.
The existing Race Hub analytics views should stay intact.
## Product Behavior
- If local data exists, users should be able to reach Race Hub without knowing a
raw session key.
- Empty local database states should be explicit and calm.
- Weekend/session rows should show dataset coverage so users understand why a
session may be partial.
- Race Hub should continue accepting `session_key` in the URL for direct links.
## Guardrails
- Do not fetch OpenF1 directly from React.
- Do not redesign every screen.
- Do not remove the manual session key entry yet; keep it as a fallback.
- Do not add a large UI framework or chart dependency.
- Keep mobile and iPad usable.
## Acceptance Criteria
- Users can select a local year, meeting, and session.
- Selecting a session opens Race Hub for that session.
- Empty states are covered.
- Existing Race Hub e2e tests continue passing.
- Add focused frontend tests for navigation behavior where practical.
- Frontend tests and build pass.

View File

@@ -0,0 +1,40 @@
# Phase 11 Weekend Ingestion
## Purpose
Phase 10 made local data navigable in the Web UI, but the app still needs a
practical way to populate a complete weekend. Phase 11 should make ingestion
work at the same shape users browse: meeting/weekend first, then sessions.
This is a backend/CLI slice for Cursor.
## Scope
Add or refine CLI ingestion so a user can ingest a whole meeting/weekend into
the domain database without manually running one command per session.
The target workflow is:
- ingest meeting metadata and sessions for a `meeting_key`;
- for each session in that meeting, ingest Race Hub datasets;
- report per-session success, partial failure, and row counts clearly;
- keep raw payload provenance.
## Guardrails
- Do not remove single-session ingestion.
- Do not fetch data from React.
- Do not make failed optional analytics endpoints destroy already-ingested
meeting/session metadata.
- Keep tests offline with fake sources.
- Be careful with live/current sessions; completed historical sessions are the
primary target.
## Acceptance Criteria
- A single CLI path can ingest all sessions for a meeting.
- Ingestion summaries make per-session results clear.
- Existing `--ingest-session` behavior still works.
- Store/query/web/frontend tests still pass.
- Add focused ingestion tests for full-weekend orchestration and partial
failures where practical.

View File

@@ -0,0 +1,40 @@
# Phase 12 Data Library UI
## Purpose
The app can now ingest full weekends and browse local seasons, meetings, and
sessions. Phase 12 should make local data health visible in the Web UI so users
can understand what is stored, what is partial, and what command to run next.
This is a frontend slice. Keep it practical and built on the APIs already
available.
## Scope
Add a Data Library style surface that shows:
- local seasons and meetings;
- sessions per meeting;
- dataset coverage per session;
- clear empty states;
- suggested CLI commands for ingestion/backfill.
This can be a new route or a tab/section reachable from the existing Race Hub
shell, depending on the current router structure.
## Guardrails
- Do not fetch OpenF1 from React.
- Do not trigger ingestion from the browser.
- Keep Race Hub direct links working.
- Reuse existing local navigation APIs unless a small backend gap is genuinely
blocking.
- Keep styling dense, operational, and restrained.
## Acceptance Criteria
- User can inspect local data coverage without opening a specific session.
- Partial weekends/sessions are visibly distinct from complete ones.
- Empty database state explains the relevant CLI command.
- Existing Race Hub navigation continues to work.
- Frontend tests/build/e2e pass.

View File

@@ -0,0 +1,127 @@
# MVP Completion Checklist
## Status
The local-first Web UI MVP is now functionally assembled. The app can ingest
OpenF1 data into the SQLite domain store, serve local-first Race Hub and
navigation APIs, render the React Race Hub/Data Library/Live Timing routes, and
serve the built React app from Go web mode when `frontend/dist` is present.
This document replaces the temporary agent handoff prompts as the main
checkpoint for what has been completed and what remains.
## Completed
- Live SignalR code extracted into `internal/live` and reused by TUI and Web
mode.
- SQLite domain store added for local historical data.
- Session, meeting, and weekend ingestion paths added with dry-run support.
- Optional analytics ingestion failures are partial, not hard blockers.
- Local-first Race Hub API added with dataset availability metadata.
- Local-first season, meeting, and weekend navigation APIs added.
- React + TypeScript frontend added with TanStack Query and Router.
- Race Hub route added for classification, grid, strategy, positions, laps,
race control, weather, and dataset status.
- Admin / Data Health route added for local season/weekend coverage and CLI
guidance; `/data-library` remains a legacy alias.
- Live Timing route added for current backend live snapshot/SSE state.
- Go web mode serves the built React app from `frontend/dist` and falls back to
embedded legacy assets when no build is present.
## Verification Commands
Run these before cutting an MVP tag or handing the repo to another agent:
```bash
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
### Dev proxy smoke (Vite + Go API)
For a local manual smoke test with the Vite dev server proxying API calls:
```bash
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-mvp.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port 18080
BOXBOX_API_PORT=18080 npm run dev --prefix frontend -- --host 127.0.0.1 --port 15173 --strictPort
```
Then open:
- `http://127.0.0.1:15173/race-hub?session_key=9472`
- `http://127.0.0.1:15173/admin`
- `http://127.0.0.1:15173/data-library` (legacy alias)
- `http://127.0.0.1:15173/live`
### Production web smoke (Go serves built React)
Verify the same routes when Go serves `frontend/dist` directly (no Vite):
```bash
npm --prefix frontend run build
go run ./scripts/seed-e2e-db/main.go --db /tmp/boxbox-mvp.db
BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port 18080
```
Then open:
- `http://127.0.0.1:18080/race-hub?session_key=9472`
- `http://127.0.0.1:18080/admin`
- `http://127.0.0.1:18080/data-library` (legacy alias)
- `http://127.0.0.1:18080/live`
Automated production-serving coverage:
```bash
npm run test:e2e:prod
```
This runs `playwright.prod.config.ts`, which builds the frontend, seeds
`.playwright/boxbox-prod-e2e.db`, starts Go web mode on port 18080, and
exercises Race Hub, Data Library, Live empty state, and nav links against the
built SPA.
### Visual regression (Playwright screenshots)
Screenshot baselines for Race Hub, Data Library, and Live (disabled-live empty
state) at desktop, tablet, and mobile viewports:
```bash
npm run test:visual
npm run test:visual:prod
```
Refresh baselines after intentional UI changes:
```bash
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots are stored under `tests/visual/__snapshots__/`. See
[22 Phase 14 Visual Regression](22-phase-14-visual-regression.md).
## Remaining Post-MVP Work
- Improve high-density mobile/iPad behavior for Live Timing and Race Hub tables.
- Add persisted live-event capture and reconciliation only after defining the
live storage model.
- Add track outline ingestion/read models to the React app if the local data
source is reliable enough.
- Expand from weekend/session ingestion toward safe full-season backfill.
- Add Drivers, Standings, and Settings as separate product phases.
- Revisit static archive feasibility after source mapping is proven.
## Notes
- `--ingest-year` currently discovers season meetings and sessions. Use
`--ingest-meeting <meeting_key>` for full weekend ingestion.
- The React app should continue avoiding direct OpenF1 reads. New Web UI routes
should call local-first Go APIs.
- The TUI live mode remains intentionally preserved. Historical Web UI parity
with the TUI is not required for this MVP.

View File

@@ -0,0 +1,55 @@
# Phase 14: Visual Regression and Responsive QA
## Goal
Add Playwright screenshot coverage for the MVP Web UI routes across desktop,
tablet, and mobile viewports before broader product expansion.
## Scope
Routes:
- `/race-hub?session_key=9472`
- `/data-library`
- `/live` (empty state with `BOXBOX_DISABLE_LIVE=1`)
Viewports (deterministic Chromium):
| Project | Size |
|---------|------|
| desktop | 1280×800 |
| tablet | 768×1024 |
| mobile | 390×844 |
## Commands
```bash
# Dev proxy (Vite + seeded Go API) — same stack as test:e2e
npm run test:visual
npm run test:visual:update
# Production serving (Go + frontend/dist) — canonical for committed snapshots
npm run test:visual:prod
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/{desktop,tablet,mobile}/`.
## Constraints
- Reuses `scripts/seed-e2e-db` and `BOXBOX_DISABLE_LIVE=1`; no live F1 session
or OpenF1 network calls.
- Screenshots are taken only after route-specific ready conditions (classification
loaded, data library detail visible, live empty state).
- Animations disabled; full-page captures; no loading-state screenshots.
## Out of Scope
- Live timing tower screenshots (requires an active session and live SignalR).
- Cross-browser matrix beyond Chromium.
- Pixel-perfect parity between Vite dev and production builds (use prod update
when refreshing committed baselines).
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)

View File

@@ -0,0 +1,48 @@
# Phase 15: Command Center V1
## Goal
Make the Web UI default route a useful local-first operations screen instead of
requiring users to know a raw Race Hub session key.
## Completed Scope
- Added `/` as the Command Center route.
- Added a top-level Command nav item while preserving Race Hub, Live, and Data
Library routes.
- Shows local season coverage, weekend coverage, local session counts, and live
availability.
- Selects a focus weekend from local data using current, upcoming, then recent
weekend priority.
- Provides quick actions into Live Timing, Race Hub for the default local
session, and Data Library.
- Lists recent local sessions with direct Race Hub links.
- Added unit coverage for schedule selection helpers and the Command Center
page.
- Added Playwright E2E and production smoke coverage for `/`.
- Added visual regression coverage for Command Center at desktop, tablet, and
mobile viewports.
## Constraints
- React continues to call only local-first Go APIs; no direct OpenF1 reads were
added.
- Live state remains read-only status from the existing Web live endpoint.
- The page stays dense and operational rather than becoming a marketing landing
page.
## Verification
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)

View File

@@ -0,0 +1,43 @@
# Phase 16: Live Timing Polish
## Goal
Improve the React Live Timing route as an operations screen while preserving the
existing official F1 SignalR bridge and TUI live behavior.
## Completed Scope
- Added pure helpers for position delta styling and race-control flag classes.
- Improved the timing tower with podium position styling, colored position
deltas, best-lap/lap-count columns, and compact status badges.
- Reworked the session banner so track status, lap count, clock, live/stale
state, and weather read as dense operational metadata.
- Improved race-control feed treatment with color-coded flag badges, category
labels for non-flag messages, and bounded scrolling.
- Reworked the live route layout into a two-column desktop view with timing
tower priority and race control alongside it.
- Improved empty and disconnected states without requiring a real live F1
session.
## Constraints
- No backend live bridge or TUI live code was changed.
- No persisted live storage was added.
- Tests continue to use disabled-live/empty-state coverage because an active F1
session is not guaranteed.
## Verification
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [23 Phase 15 Command Center](23-phase-15-command-center.md)

View File

@@ -0,0 +1,97 @@
# Phase 18: Fan Command Center and Admin Split
## Goal
Shift the Web UI from a local-data console toward an F1 fan race-weekend
command center, while keeping the dark, dense, ops-room aesthetic. Move
local-data and ingestion concerns into a thin Admin / Data Health area so they
no longer dominate the fan-facing first screen.
## Completed Scope
- Reworked `/` (`CommandCenterPage`) around the focus race weekend:
- GP identity band with country decal, GP name, location, circuit, date
range, status eyebrow (Live now / Current / Next / Recent), and a
country-derived left-edge accent strip.
- Countdown block — LIVE state, current-session label, next-session
`Xd HHh MMm SSs` countdown, or "Weekend finished" — anchored to the band.
- Primary actions row: Watch Live, Open Analysis (preselects race → qual →
first local session), Schedule jump.
- Session schedule as responsive cards instead of a table, with per-session
coverage dot, status pill, and direct Race Hub link (keeps the existing
`cc-session-{key}` testid).
- Recent local weekends rendered as a chip strip with country decals.
- Empty state reframed: short eyebrow, single-paragraph instruction, and
links to Live + Admin (no inline CLI on the fan-facing surface).
- Added `frontend/src/lib/gpIdentity.ts` for country accent, 3-letter decal,
and short date-range formatting.
- Reframed `DataLibraryPage` as **Admin · Data Health**: page header,
utility-style stats banner (seasons / full / partial / missing), and a
back-link to Command Center in the footer.
- Added `/admin` route rendering the same Data Health page. `/data-library`
remains as a legacy alias so existing links keep working.
- Updated `Nav` so primary fan destinations (Command / Live / Race Hub) sit
next to the logo, and a small monospace **Admin** chip is anchored to the
far right as a utility link.
## What Moved Into Admin / Data Health
- Five-stat coverage strip (Seasons / Full / Partial / Missing / Sessions
Local) — now lives in the admin banner.
- CLI ingest guidance — only shown under Admin and inside meeting detail.
- Per-meeting weekend table, per-session dataset status panel, and ingest
command blocks — unchanged content, but no longer reachable from the fan
nav directly.
## What Did Not Change
- Race Hub session-key table flow is unchanged (deeper rework is deferred).
- No new backend endpoints. Command Center still calls the existing local-first
APIs (`/api/v1/seasons`, `/api/v1/meetings`, `/api/v1/weekend`,
`/api/v1/live/state`).
- Live SignalR bridge, Live Timing page, and TUI live mode are untouched.
## Tests and Visual Coverage
Updated:
- `frontend/src/test/CommandCenterPage.test.tsx` — covers new band, decal,
empty-state copy, and analysis action label.
- `tests/command-center.spec.ts` — exercises new actions container and the
reframed admin route alongside existing routes.
- `tests/data-library.spec.ts` — renamed describe block, exercises both
`/admin` and the `/data-library` alias, uses the new Admin nav link.
- `tests/production-smoke.spec.ts` — covers `/admin`, the legacy
`/data-library` route, and the updated nav labels.
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/{command-center,data-library}.png`
regenerated against the new layout.
## Verification
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
All commands above were run in this branch and pass: 94 unit tests, 15 E2E,
6 prod smoke, 12 visual baseline, 12 prod visual baseline.
## Limitations and Follow-ups
- The Schedule action only renders when a next session exists. During a
live session the row shows two actions instead of three; intentional.
- Country accent palette in `gpIdentity.ts` is a hand-tuned subset of country
codes; unknown codes fall back to a neutral gray.
- Race Hub remains the next target — its session-selection flow is still the
most dated part of the fan path, especially on tablet.
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
- [23 Phase 15 Command Center](23-phase-15-command-center.md)
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md)

View File

@@ -0,0 +1,128 @@
# Phase 19: Weekend Workspace / Race Hub Flow V1
## Goal
Rework `/race-hub` from an "admin-style table on top, analysis below" page
into a Weekend Workspace that feels like a modern session companion: a
compact GP identity band, a horizontal session rail, an Overview snapshot,
and fan-oriented analysis tabs. Keep `/race-hub?session_key=…` working,
keep ingestion/admin concerns out of the fan surface, and make mobile/iPad
first-class.
## Completed Scope
- Replaced the legacy Race Hub layout with a Weekend Workspace:
- **Topbar**: `box-box · race hub · <year>` eyebrow, weekend source badge,
and a `Switch Weekend` toggle that opens an inline switcher panel.
- **GP identity band**: country decal, GP name, location · circuit, date
range, with a left-edge `--gp-accent` strip derived from
`frontend/src/lib/gpIdentity.ts`.
- **Session rail**: horizontal segmented strip of session cards (FP1,
FP2, FP3, Q, Sprint, R …) showing abbreviation, name, time, source dot,
and coverage hint. Switching is a single click; selected chip pulses
with the GP accent.
- **Active session sub-bar**: compact line with session name, scheduled
time, coverage label, and `key <session_key>` for power users.
- New tab grouping (`frontend/src/components/TabBar.tsx`):
Overview · Race Story · Strategy · Lap Data · Conditions · Race Control ·
Data Status. Race Story bundles classification, starting grid, and
position evolution behind a sub-segmented control so the operational
feel is preserved without exploding the top-level tab list.
- New **Overview** tab (`components/OverviewView.tsx`): operational stat
cards (Winner / Pole / Fastest Lap / Podium) plus compact panels for
Conditions, latest Race Control messages, and a Local Coverage meter
that links to the relevant Data Status tab.
- Inline **Weekend Switcher** (`components/WeekendSwitcher.tsx`): season
tabs, meeting cards with country decals, and expandable per-meeting
session lists that navigate via `useNavigate`. Replaces the old fullscreen
`LocalDataNavigator` table on the Race Hub surface.
- Auto-resolution when `/race-hub` is opened without `session_key`: the
page resolves the focus weekend via the same `pickFocusMeeting` helper
Command Center uses and `navigate(replace: true)` to the focus
session (race → qualifying → first local session).
- **Data Status** tab now points at `/admin` for missing datasets instead of
inlining CLI commands. Admin remains the home for ingestion guidance.
- GP accent is plumbed through CSS custom property `--gp-accent`, used by
session chips, story sub-control underline, overview stat cards, and the
topbar `Switch Weekend` border.
## Route Behavior
- `/race-hub?session_key=9472` — unchanged contract; loads the workspace
for that session and opens Overview by default.
- `/race-hub` (no key) — resolves locally via `fetchSeasons`
`fetchLocalMeetings``pickFocusMeeting``fetchWeekend`, then
`navigate({ replace: true })` to the focus session's race/qualifying.
- `/data-library` and `/admin` remain untouched.
## What Did Not Change
- Backend APIs (`/api/v1/race-hub`, `/api/v1/seasons`, `/api/v1/meetings`,
`/api/v1/weekend`).
- Live SignalR bridge, `/live` page, TUI live mode.
- Command Center, Admin / Data Health flows.
- Existing chart and table components (`ClassificationTable`,
`StartingGridTable`, `StrategyView`, `PositionEvolutionView`, `LapsView`,
`RaceControlView`, `WeatherView`) are reused inside the new shell.
- The legacy `LocalDataNavigator` component is kept (still unit-tested) so
any future surfaces can reuse it, but it is no longer mounted on
`/race-hub`.
## Tests and Visual Coverage
Updated:
- `frontend/src/test/TabBar.test.tsx` — new tab list (Overview / Race Story
/ Strategy / Lap Data / Conditions / Race Control / Data Status).
- `frontend/src/test/DatasetStatusView.test.tsx` — rewritten against the
fan-facing dataset list (11/11), the new `Manage ingestion → /admin`
link, and the removal of inline CLI hints.
- `frontend/src/test/RaceHubPage.test.tsx` — new test file covering the
identity band, session rail, Race Story sub-controls, Data Status admin
link, and the inline weekend switcher.
- `tests/race-hub.spec.ts` — rewritten E2E spec covering Overview default,
Race Story sub-views, Strategy and Positions missing-data notices,
weekend switcher toggle, Data Status admin link, and the bare
`/race-hub` redirect.
- `tests/command-center.spec.ts`, `tests/data-library.spec.ts`,
`tests/production-smoke.spec.ts` — updated assertions to land on the
new workspace shell rather than the old "Final Classification" headline.
- `tests/visual/helpers.ts``gotoRaceHubReady` now waits for
`race-hub` + `rh-identity` + `rh-session-<key>` + `rh-overview`.
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/race-hub.png`
regenerated. Command Center, Admin, and Live snapshots untouched.
## Verification
```bash
npm --prefix frontend test -- --run # 98 unit tests pass
npm --prefix frontend run build # tsc + vite, clean
npm run test:e2e # 18 E2E pass
npm run test:e2e:prod # 6 prod smoke pass
npm run test:visual # 12 baseline pass after regen
npm run test:visual:prod # 12 prod baseline pass after regen
```
## Limitations and Follow-ups
- The Race Story tab keeps three legacy datasets behind a sub-segmented
control. A future pass could merge classification + position evolution
into a single scrollable "story" canvas.
- Overview's "Fastest Lap" picks the minimum non-pit-out `lap_duration` from
the ingested laps payload. Sessions that don't ingest laps show "No data
ingested" — accurate, but a future phase could fall back to OpenF1's
`fastest_lap` field if/when that lands locally.
- The inline weekend switcher fetches the active meeting's `/weekend`
payload only when expanded. Switching seasons or browsing many
meetings does not pre-warm sibling weekend queries; this is intentional
to avoid the N×weekend fan-out that Command Center already pays.
- Visual baselines are regenerated against the current seeded e2e DB. If
the seeded session list grows, the desktop snapshot will widen.
- Country accents in `gpIdentity.ts` remain a hand-tuned subset; unknown
codes fall back to a neutral gray (same behavior as Command Center).
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md)

View File

@@ -0,0 +1,110 @@
# Phase 19B Paddock Briefing RSS Backend Spike
## Goal
Prototype the backend foundation for a future fan-facing Paddock Briefing module
without scraping article pages or touching Race Hub UI. The spike keeps news as a
local-first cache: feeds can be fetched and normalized by backend code, stored in
SQLite, and read through a small API shape.
## Source Evaluation
Recommended first-party or publisher-owned feeds:
- FIA official RSS, `https://www.fia.com/rss/news`: keep as the official source
for federation announcements and regulatory context.
- BBC Sport F1, `https://feeds.bbci.co.uk/sport/formula1`: strong free headline
source for UK-centered coverage.
- Autosport F1, `https://www.autosport.com/rss/f1/news/`: useful motorsport
specialist feed; retain summaries only when provided by the feed.
- RaceFans F1, `https://www.racefans.net/category/f1-news/feed/`: useful
independent specialist feed with a clean WordPress RSS surface.
- Guardian Formula One,
`https://www.theguardian.com/sport/formulaone/rss`: broad editorial coverage
and stable RSS conventions.
Optional sources to evaluate before shipping:
- RACER F1, `https://racer.com/f1/feed`: reasonable supplemental specialist
feed.
- Formula 1 YouTube Atom,
`https://www.youtube.com/feeds/videos.xml?channel_id=UCB_qr75-ydFVKSF9Dmo6izg`:
video-only briefing cards, separate from article news.
- Motorsport.com F1, `https://www.motorsport.com/rss/f1/news/`: not included in
the default prototype list until terms and caching expectations are reviewed.
Avoid Formula1.com scraping or hidden endpoints, X/Twitter scraping, Reddit as a
primary news source, and feed aggregator products such as RSS.app or Feedspot.
## Legal And Product Caveats
Only fetch publisher-provided RSS/Atom XML. Do not fetch article bodies, bypass
paywalls, scrape Open Graph metadata, or store full article content. The product
surface should show source, title, canonical URL, publish time, category, and a
short feed-provided summary/snippet when available. Each card should link users
to the publisher site for the article.
Before enabling a source by default, review the publisher feed terms, robots/TOS
language around caching, and whether feed summaries are intended for display.
Keep TTLs conservative and make source attribution visible in the UI.
## Implemented Proof
This spike adds:
- `internal/news`: a standard-library RSS/Atom parser and polite fetch helper
with a 10-second default timeout and a box-box User-Agent.
- URL-based deduplication with UTM parameter stripping.
- `news_sources` and `news_items` tables in SQLite migration `003_news.sql`.
- Store/query methods for upserting cached feed metadata/items and listing
newest cached items.
- `GET /api/v1/news`, with optional `limit` and `source` query params.
- Unit tests using local XML fixtures only.
The endpoint is intentionally read-only against the local SQLite cache. It does
not fetch feeds during web requests, avoiding unexpected network work in the
product UI path. A later ingestion command can call `internal/news.Fetch`, upsert
sources/items, and mark `fetched_at`/`expires_at` according to a TTL policy.
## API Shape
`GET /api/v1/news?limit=25&source=racefans-f1`
Response:
```json
[
{
"source": "racefans-f1",
"title": "Example headline",
"url": "https://publisher.example/story",
"published_at": "2026-05-25T14:00:00Z",
"summary": "Feed-provided snippet",
"category": "news",
"fetched_at": "2026-05-25T14:10:00Z"
}
]
```
Default limit is 25; maximum accepted limit is 100.
## Follow-Up Frontend Plan
Add a Paddock Briefing surface outside Race Hub while Race Hub redesign work is
active. Recommended first UI slice:
- Query `/api/v1/news?limit=12`.
- Group by recency with source badges and external-link treatment.
- Show snippets only when present, with clear publisher attribution.
- Add source filters after the cache refresh command exists.
- Treat video feed items as a separate rail or filter, not mixed into hard-news
headlines by default.
## Open Questions
- Should news refresh live behind an explicit CLI command, opportunistic startup
refresh, or a manual button in an admin/data-health screen?
- What default TTL should each source use? A 15-30 minute TTL is reasonable for
race weekends; longer may be enough outside live sessions.
- Should the cache keep historical briefing items indefinitely, or prune after a
rolling window such as 30-90 days?

View File

@@ -0,0 +1,235 @@
# Orchestrator Handoff
You are taking over as the primary orchestration/coding agent for the box-box refactor.
Repo:
```text
/Users/aman/HomeBase/Programming/Projects/box-box
```
Branch:
```text
box-refactor
```
## Role
You are the engineering manager/orchestrator. Inspect before acting, keep changes scoped, review agent work before committing, prune stale docs after phases, and commit cleanly after each accepted phase. The user prefers Cursor for backend/test/hardening work and Claude for major frontend/product/design work, but you may implement directly when appropriate.
## Operating Rules
- Do not rush into implementation if the user wants to discuss.
- If implementing, keep phases small and commit-ready.
- Commit after each completed/reviewed phase.
- Never revert user/other-agent changes without explicit permission.
- Use `rg` for searches.
- Use `apply_patch` for manual edits.
- For frontend work, run browser or Playwright verification where practical.
- For review requests, lead with findings and file/line references.
- `frontend/dist` is ignored and should not be committed.
- Preserve TUI live mode and official F1 SignalR live behavior carefully.
## Project Direction
box-box started as a Go Bubble Tea F1 TUI backed mostly by OpenF1. The refactor direction is now:
- Web UI is the primary product surface.
- React + TypeScript frontend is the production Web UI stack.
- Go backend remains the API/server.
- Historical/completed-session data should be local-first from SQLite.
- OpenF1 ingestion is explicit via CLI, not fetched live on every page load.
- Official F1 SignalR remains the live source.
- Desired product feel: clean, dense, technical F1 operations room. Avoid card-heavy AI-slop.
## Important Commands
```bash
git status --short
git log --oneline -10
go test ./...
npm --prefix frontend test -- --run
npm --prefix frontend run build
npm run test:e2e
npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
```
Known test note: do not run Playwright suites that share the same seeded SQLite DB in parallel. Run prod E2E and prod visual sequentially, or they may hit `database is locked`.
## Recent Commits
- `a0f135a Update operator documentation`
- `79b0b9f Rework command center for race weekends`
- `84a8827 Add paddock briefing RSS backend spike`
- `ee88a07 Add paddock briefing feed ingestion`
- `Rework Race Hub as weekend workspace` (latest Phase 19 commit)
## Current State
Phase 19 and Phase 20 have been reviewed and committed. Start new work from a
clean tree unless `git status --short` shows user changes made after this
handoff.
## Completed Phase 19: Weekend Workspace / Race Hub Flow V1
Claude implemented, and Codex reviewed/committed, a Race Hub redesign that turns
`/race-hub` into a Weekend Workspace.
- New:
- `frontend/src/components/OverviewView.tsx`
- `frontend/src/components/WeekendSwitcher.tsx`
- `frontend/src/test/RaceHubPage.test.tsx`
- `documentations/refactor/26-phase-19-weekend-workspace.md`
- Modified:
- `frontend/src/pages/RaceHubPage.tsx`
- `frontend/src/components/TabBar.tsx`
- `frontend/src/components/DatasetStatusView.tsx`
- `frontend/src/styles/app.css`
- tests for TabBar, DatasetStatusView, race-hub, command-center, data-library, production-smoke
- `tests/visual/helpers.ts`
- race-hub visual snapshots
- root `README.md`
- `documentations/refactor/README.md`
UX changes:
- Race Hub is now a Weekend Workspace.
- Compact GP identity band with country decal/accent strip.
- Horizontal session rail replaces “big table then analysis below.”
- Active session context stays visible above tabs.
- Tabs regrouped into Overview, Race Story, Strategy, Lap Data, Conditions, Race Control, Data Status.
- New Overview tab with winner/pole/fastest/podium cards, condition chips, latest race control, and local coverage meter.
- Inline Switch Weekend panel replaces legacy LocalDataNavigator on Race Hub.
- Data Status links to `/admin`; no CLI/admin text on fan surface.
- Mobile/iPad improved with wrapping identity band, horizontal session rail, single-column stats.
- `/race-hub?session_key=9472` still works and loads Bahrain GP 2024 seeded session.
- Bare `/race-hub` now resolves to a focus weekend/session via `pickFocusMeeting` and navigation replace.
Verification run by Codex before commit:
- `npm --prefix frontend test -- --run`
- `npm --prefix frontend run build`
- `npm run test:e2e`
- `npm run test:e2e:prod`
- `npm run test:visual`
- `npm run test:visual:prod`
Small review fix included: `frontend/src/test/setup.ts` stubs
`window.scrollTo` so TanStack Router scroll restoration does not spam jsdom test
stderr.
## Completed Phase 20: Paddock Briefing Ingestion CLI
A backend subagent implemented, and Codex reviewed/committed, Phase 20 after the
RSS backend spike.
Phase 20 changes:
- Modified:
- `cmd/main.go`
- New:
- `internal/news/refresh.go`
- `internal/news/refresh_test.go`
- `documentations/refactor/29-phase-20-paddock-briefing-ingestion.md`
Behavior:
- Adds `--ingest-news` as a CLI mode.
- Keeps it mutually exclusive with `--ingest-year`, `--ingest-meeting`, and
`--ingest-session`.
- Reuses `--db` for the domain SQLite path.
- Reuses `--dry-run` to fetch and report feed counts without opening or writing
the domain database.
- Uses `internal/news.Refresh`, which fetches `DefaultSources`, upserts
`news_sources`, upserts URL-deduped `news_items`, records `fetched_at` and
`expires_at`, and continues through individual feed failures before returning
a summary error.
- Web requests still do not fetch feeds; `/api/v1/news` remains read-only
against SQLite.
Commands added:
```bash
go run ./cmd/main.go --ingest-news
go run ./cmd/main.go --dry-run --ingest-news
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
```
Verification run by Codex before commit:
```bash
go test ./cmd/... ./internal/news ./internal/store
git diff --check
```
## Immediate Task
Start with a quick sync:
```bash
git status --short
git diff --stat
```
Then continue with the next requested phase. The most natural next phase is
Phase 21: Paddock Briefing UI, unless the user wants to deepen Race Story first.
## RSS / Paddock Briefing Context
Cursor completed and Codex committed a backend spike as `84a8827 Add paddock briefing RSS backend spike`.
Implemented:
- `internal/news`: RSS/Atom parser and fetch helper.
- SQLite tables:
- `news_sources`
- `news_items`
- Store/query methods for cached news.
- Read-only API:
- `GET /api/v1/news?limit=25&source=racefans-f1`
- No request-time network fetching.
- Unit tests use local XML fixtures.
Recommended feed sources:
- FIA official RSS
- BBC Sport F1
- Autosport F1
- RaceFans F1
- Guardian Formula One
Optional:
- Motorsport.com
- RACER
- Formula 1 YouTube Atom
Avoid:
- Formula1.com scraping/hidden endpoints
- X/Twitter scraping
- Reddit as primary source
- RSS.app/Feedspot as primary source
## Likely Next Phases After Phase 19 And 20
1. Phase 21: Paddock Briefing UI
- Claude/frontend.
- Add fan-facing briefing module, likely on Command Center first.
- Query `/api/v1/news`.
- Show source, title, age, category, short feed-provided snippet, external link.
- Keep publisher attribution visible.
- Avoid full article storage or scraping.
2. Phase 22: Race Story Deepening
- Claude/frontend or mixed.
- Collapse legacy classification/grid/position components into a more fluid Race Story canvas.
- Improve mobile scanning and session narrative.
3. Phase 23: Full Season Backfill / ingest hardening
- Cursor/backend.
- Safer season workflows, resumability, rate-limit controls, coverage reporting.

View File

@@ -0,0 +1,47 @@
# Phase 20 Paddock Briefing Ingestion CLI
## Goal
Turn the Phase 19B RSS backend spike into an explicit local refresh command for
the Paddock Briefing cache. Feed fetching remains a CLI-only operation; web
requests continue to read SQLite only.
## Implemented
- Added `--ingest-news` as a CLI ingestion mode on `cmd/main.go`.
- Reused `--db` path behavior from the existing OpenF1 ingestion flows.
- Reused `--dry-run` to fetch and report feed counts without opening or writing
the domain database.
- Added `internal/news.Refresh`, which:
- fetches `internal/news.DefaultSources` unless tests provide a custom list;
- uses `internal/news.Fetch` with a 10-second HTTP client timeout;
- upserts `news_sources` with `fetched_at` and `expires_at`;
- upserts URL-deduped `news_items`;
- continues after individual source failures and returns a summary error after
successful sources are stored.
## CLI
```bash
go run ./cmd/main.go --ingest-news
go run ./cmd/main.go --dry-run --ingest-news
go run ./cmd/main.go --ingest-news --db /tmp/boxbox.db
```
`--ingest-news` is mutually exclusive with `--ingest-year`, `--ingest-meeting`,
and `--ingest-session`.
## Verification
Tests use local `httptest.Server` feeds only. No live internet test is required
for the refresh logic.
```bash
go test ./internal/news ./internal/store
```
## Notes
The refresh TTL is currently 30 minutes for all sources. A future phase can add
source-specific TTLs, retention/pruning, or admin UI controls without changing
the read-only `/api/v1/news` contract.

View File

@@ -0,0 +1,128 @@
# box-box Refactor Brief
## Operator guide
For day-to-day build, run, ingest, test, and Web route commands, use the
top-level [README.md](../../README.md). This directory is for architecture,
phasing, and research — not the first stop for new contributors.
## 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.
- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend
implementation slice for the production Web UI.
- [14 Phase 6 React Race Hub Analytics](14-phase-6-react-race-hub-analytics.md):
next frontend slice for strategy, position, and richer Race Hub views.
- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md):
backend slice for laps, stints, pits, race control, weather, and positions.
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
slice for turning the newly available analytics datasets into useful Race Hub
views.
- [17 Phase 9 Navigation Data API](17-phase-9-navigation-data-api.md): backend
slice for local-first season/weekend/session navigation so users do not need
raw session keys.
- [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for
adding local-first season/weekend navigation around Race Hub.
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
slice for making one command ingest a whole race weekend into the local DB.
- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
for showing local ingestion coverage and next CLI actions.
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md): current
implementation status, verification commands, and remaining post-MVP work.
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
screenshot coverage for MVP routes and responsive viewports.
- [23 Phase 15 Command Center](23-phase-15-command-center.md): default Web
entry screen for local coverage, weekend focus, live status, and next actions.
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser
React live timing layout, status treatment, and race-control polish.
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
`/` around race-weekend identity and splits ingestion/admin concerns into
the new `/admin` (Data Health) route.
- [26 Phase 19 Weekend Workspace](26-phase-19-weekend-workspace.md): rebuilds
`/race-hub` as a session-card-rail workspace with Overview / Race Story /
Strategy / Lap Data / Conditions / Race Control / Data Status tabs,
an inline weekend switcher, and GP-accent identity treatment.
- [27 Phase 19B Paddock Briefing RSS](27-phase-19b-paddock-briefing-rss.md):
backend spike for publisher-owned RSS/Atom feeds, local SQLite caching, and a
future fan-facing briefing API.
- [29 Phase 20 Paddock Briefing Ingestion](29-phase-20-paddock-briefing-ingestion.md):
backend CLI slice for refreshing RSS/Atom feeds into the local news cache.
## 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.

33
frontend/README.md Normal file
View File

@@ -0,0 +1,33 @@
# box-box Web Frontend
React Race Hub slice for the production Web UI.
## Commands
```bash
npm install
npm run dev
npm test -- --run
npm run build
```
The Vite dev server proxies `/api` requests to the Go server on
`http://localhost:8080`.
Run the backend separately:
```bash
go run cmd/main.go --web
```
Then open the React app, usually:
```text
http://localhost:5173/race-hub
```
Load an ingested session with:
```text
http://localhost:5173/race-hub?session_key=9472
```

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>box-box</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4448
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "box-box-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

83
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { ArticleContent, LiveStateResponse, Meeting, NewsItem, RaceHub, Weekend } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchSeasons(): Promise<number[]> {
const res = await fetch('/api/v1/seasons')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const years = await res.json()
return Array.isArray(years) ? years : []
}
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const meetings = await res.json()
return Array.isArray(meetings) ? meetings : []
}
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const meetings = await res.json()
return Array.isArray(meetings) ? meetings : []
}
export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString())
if (source) params.set('source', source)
const query = params.toString()
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
const res = await fetch(url)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchNewsArticle(articleUrl: string): Promise<ArticleContent> {
const res = await fetch(`/api/v1/news/article?url=${encodeURIComponent(articleUrl)}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function markNewsRead(articleUrl: string): Promise<void> {
await fetch('/api/v1/news/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl }),
})
}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
interface Command {
comment?: string
cmd: string
}
interface Props {
commands: Command[]
}
export function CliCommands({ commands }: Props) {
return (
<div className="cli-block" data-testid="cli-commands">
{commands.map(({ comment, cmd }, i) => (
<div key={cmd} className="cli-entry">
{comment && <div className="cli-comment">{comment}</div>}
<CliCommandLine cmd={cmd} />
{i < commands.length - 1 && <div className="cli-spacer" />}
</div>
))}
</div>
)
}
function CliCommandLine({ cmd }: { cmd: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(cmd)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard may be unavailable in tests
}
}
return (
<div className="cli-cmd-row">
<code className="cli-cmd">{cmd}</code>
<button type="button" className="cli-copy-btn" onClick={handleCopy} aria-label={`Copy ${cmd}`}>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
)
}
export function ingestYearCommands(year: number): Command[] {
return [
{ comment: '# Discover season meetings and sessions', cmd: `box-box --ingest-year ${year}` },
{ comment: '# Preview season discovery only', cmd: `box-box --ingest-year ${year} --dry-run` },
]
}
export function ingestMeetingCommands(meetingKey: number): Command[] {
return [
{ comment: '# Full weekend ingest (all sessions)', cmd: `box-box --ingest-meeting ${meetingKey}` },
{ comment: '# Preview without downloading', cmd: `box-box --ingest-meeting ${meetingKey} --dry-run` },
]
}
export function ingestSessionCommands(sessionKey: number): Command[] {
return [{ comment: '# Race Hub datasets for one session', cmd: `box-box --ingest-session ${sessionKey}` }]
}

View File

@@ -0,0 +1,88 @@
import { Link } from '@tanstack/react-router'
import { RACE_HUB_DATASETS } from '../lib/coverage'
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
const DATASET_LABELS: Record<string, string> = {
meeting: 'Meeting',
session: 'Session',
drivers: 'Drivers',
results: 'Results',
starting_grid: 'Starting Grid',
stints: 'Stints',
pit_stops: 'Pit Stops',
positions: 'Positions',
race_control: 'Race Control',
weather: 'Weather',
laps: 'Laps',
}
export function DatasetStatusView({ datasets }: Props) {
const entries = RACE_HUB_DATASETS.map((key) => ({
key,
label: DATASET_LABELS[key] ?? key,
info: datasets[key] as DatasetInfo | undefined,
}))
const available = entries.filter((e) => e.info?.status === 'available').length
const total = entries.length
const missing = total - available
return (
<div data-testid="rh-data-status">
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(available / total) * 100}%` }}
/>
</div>
<div className="ds-legend">
<span className="mono" style={{ color: 'var(--text-2)' }}>
{available}/{total} datasets local
</span>
{missing > 0 && (
<span style={{ color: 'var(--text-3)' }}>
{missing} dataset{missing === 1 ? '' : 's'} still missing {' '}
<Link to="/admin" className="rh-inline-link">
manage ingestion
</Link>
.
</span>
)}
</div>
<table className="data-table" style={{ maxWidth: 480 }}>
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{entries.map(({ key, label, info }) => (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{label}
</td>
<td>
{info?.status === 'available' ? (
<span className="badge badge-local">Local</span>
) : info?.status === 'skipped' ? (
<span className="badge badge-none">N/A</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import type { DatasetInfo } from '../types'
const DATASET_LABELS: Record<string, string> = {
meeting: 'meeting',
session: 'session',
drivers: 'drivers',
results: 'results',
starting_grid: 'grid',
}
interface Props {
datasets: Record<string, DatasetInfo>
}
export function DatasetStrip({ datasets }: Props) {
const keys = Object.keys(DATASET_LABELS)
return (
<div className="dataset-strip">
{keys.map((key) => {
const info = datasets[key]
const available = info?.status === 'available' || info?.status === 'skipped'
return (
<div key={key} className="ds-item" title={info ? `${info.status === 'skipped' ? 'N/A' : info.source} · ${info.count ?? 0} rows` : 'missing'}>
<div className={`ds-dot ${available ? 'ds-dot-local' : 'ds-dot-missing'}`} />
<span>{DATASET_LABELS[key]}</span>
{available && info.count != null && info.count > 0 && (
<span style={{ opacity: 0.5 }}>·{info.count}</span>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { teamColor } from '../utils'
interface Props {
acronym: string
number: number
colour: string
}
export function DriverCell({ acronym, number, colour }: Props) {
return (
<div className="drv-cell">
<div className="drv-bar" style={{ background: teamColor(colour) }} />
<span className="drv-code">{acronym}</span>
<span className="drv-num">{number}</span>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import type { Driver, Lap } from '../types'
import { formatGap, formatLapTime, teamColor } from '../utils'
interface Props {
laps: Lap[]
drivers?: Driver[]
}
interface DriverLapSummary {
driver_number: number
total: number
best: Lap | null
lastLap: number
pitOuts: number
}
export function LapsView({ laps, drivers = [] }: Props) {
if (laps.length === 0) {
return (
<div className="missing-notice">
Laps not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code> to
load this dataset.
</div>
)
}
const byDriver = new Map<number, DriverLapSummary>()
for (const lap of laps) {
const summary =
byDriver.get(lap.driver_number) ??
{
driver_number: lap.driver_number,
total: 0,
best: null,
lastLap: 0,
pitOuts: 0,
}
summary.total += 1
summary.lastLap = Math.max(summary.lastLap, lap.lap_number)
if (lap.is_pit_out_lap) summary.pitOuts += 1
if (
lap.lap_duration != null &&
lap.lap_duration > 0 &&
(!summary.best ||
summary.best.lap_duration == null ||
lap.lap_duration < summary.best.lap_duration)
) {
summary.best = lap
}
byDriver.set(lap.driver_number, summary)
}
const rows = [...byDriver.values()].sort((a, b) => {
const aBest = a.best?.lap_duration ?? Number.POSITIVE_INFINITY
const bBest = b.best?.lap_duration ?? Number.POSITIVE_INFINITY
if (aBest !== bBest) return aBest - bBest
return a.driver_number - b.driver_number
})
const driversByNumber = new Map(drivers.map((driver) => [driver.driver_number, driver]))
const fastest = rows.find((row) => row.best?.lap_duration != null)?.best
const fastestTime = fastest?.lap_duration ?? null
return (
<div className="scroll-x" data-testid="laps-view">
<table className="data-table" style={{ minWidth: 540, maxWidth: 700 }}>
<thead>
<tr>
<th>Driver</th>
<th className="c">Best Lap</th>
<th className="r">Best Time</th>
<th className="r">Gap</th>
<th className="r hide-mobile">Laps</th>
<th className="r hide-mobile">Pit Outs</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const driver = driversByNumber.get(row.driver_number)
const driverName =
driver?.full_name || driver?.broadcast_name || driver?.name_acronym || `#${row.driver_number}`
const colour = teamColor(driver?.team_colour)
const isFastest =
fastest &&
row.best?.driver_number === fastest.driver_number &&
row.best?.lap_number === fastest.lap_number
const gap =
row.best?.lap_duration != null && fastestTime != null
? row.best.lap_duration - fastestTime
: null
return (
<tr key={row.driver_number} className={isFastest ? 'lap-fastest-row' : undefined}>
<td style={{ fontWeight: 700 }}>
<span className="drv-cell">
<span className="drv-bar" style={{ background: colour }} />
<span>{driverName}</span>
<span className="drv-num">{row.driver_number}</span>
</span>
</td>
<td className="c mono">
{row.best ? row.best.lap_number : '—'}
</td>
<td className="r">{formatLapTime(row.best?.lap_duration)}</td>
<td className="r">{isFastest ? '—' : formatGap(gap)}</td>
<td className="r hide-mobile">{row.lastLap || row.total}</td>
<td className="r hide-mobile" style={{ color: 'var(--text-3)' }}>
{row.pitOuts || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,268 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import type { Meeting, WeekendSession } from '../types'
function formatMeetingDates(meeting: Meeting): string {
const start = meeting.date_start?.slice(0, 10)
const end = meeting.date_end?.slice(0, 10)
if (start && end && start !== end) return `${start} ${end}`
return start || end || '—'
}
function sessionSourceBadge(source: WeekendSession['source']) {
return <SourceBadge source={source} />
}
interface Props {
onSelectSession?: (sessionKey: number) => void
}
export function LocalDataNavigator({ onSelectSession }: Props) {
const navigate = useNavigate()
const [selectedYear, setSelectedYear] = useState<number | null>(null)
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(null)
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear],
queryFn: () => fetchLocalMeetings(selectedYear!),
enabled: selectedYear != null,
})
const weekendQuery = useQuery({
queryKey: ['weekend', selectedMeetingKey],
queryFn: () => fetchWeekend(selectedMeetingKey!),
enabled: selectedMeetingKey != null,
})
useEffect(() => {
if (seasonsQuery.data?.length && selectedYear == null) {
setSelectedYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, selectedYear])
function handleSelectSession(sessionKey: number) {
if (onSelectSession) {
onSelectSession(sessionKey)
return
}
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
}
function handleSelectYear(year: number) {
setSelectedYear(year)
setSelectedMeetingKey(null)
}
function handleSelectMeeting(meetingKey: number) {
setSelectedMeetingKey((prev) => (prev === meetingKey ? null : meetingKey))
}
if (seasonsQuery.isLoading) {
return <div className="nav-panel loading-state">loading local seasons</div>
}
if (seasonsQuery.isError) {
return (
<div className="nav-panel error-box">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="nav-panel" data-testid="local-nav-empty">
<div className="nav-panel-title">Local Data</div>
<div className="empty-state" style={{ padding: 'var(--s5) 0' }}>
<div className="empty-state-title">No ingested seasons yet</div>
<div className="empty-state-desc">
Ingest a session with <code>box-box --ingest-session &lt;key&gt;</code>, then browse
here or enter a session key below.
</div>
</div>
</div>
)
}
const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data
return (
<div className="nav-panel" data-testid="local-nav">
<div className="nav-panel-head">
<span className="nav-panel-title">Local Data</span>
<div className="year-list" role="listbox" aria-label="Season">
{seasons.map((year) => (
<button
key={year}
type="button"
role="option"
aria-selected={year === selectedYear}
className={`year-btn ${year === selectedYear ? 'active' : ''}`}
onClick={() => handleSelectYear(year)}
>
{year}
</button>
))}
</div>
</div>
{meetingsQuery.isLoading && (
<div className="nav-section-meta">loading meetings</div>
)}
{meetingsQuery.isError && (
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
</div>
)}
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
<div className="nav-section-meta">No meetings ingested for {selectedYear}.</div>
)}
{meetings.length > 0 && (
<div className="nav-section">
<div className="sec-header">
<span className="sec-title">Meetings</span>
<span className="sec-meta">{meetings.length}</span>
</div>
<div className="scroll-x">
<table className="data-table nav-table">
<thead>
<tr>
<th>Grand Prix</th>
<th className="hide-mobile">Country</th>
<th className="hide-mobile">Dates</th>
<th className="r">Open</th>
</tr>
</thead>
<tbody>
{meetings.map((meeting) => {
const selected = meeting.meeting_key === selectedMeetingKey
return (
<tr
key={meeting.meeting_key}
className={selected ? 'nav-row-selected' : ''}
data-testid={`meeting-row-${meeting.meeting_key}`}
>
<td>
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
{meeting.circuit_short_name && meeting.circuit_short_name !== meeting.meeting_name && (
<span className="nav-sub">{meeting.circuit_short_name}</span>
)}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
{meeting.country_code || meeting.country_name}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{formatMeetingDates(meeting)}
</td>
<td className="r">
<button
type="button"
className={`nav-action-btn ${selected ? 'active' : ''}`}
aria-expanded={selected}
onClick={() => handleSelectMeeting(meeting.meeting_key)}
>
{selected ? 'Hide' : 'Sessions'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{selectedMeetingKey != null && weekendQuery.isLoading && (
<div className="nav-section-meta">loading sessions</div>
)}
{selectedMeetingKey != null && weekendQuery.isError && (
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
</div>
)}
{weekend && (
<div className="nav-section" data-testid="weekend-sessions">
<div className="sec-header">
<span className="sec-title">{weekend.meeting.meeting_name} Sessions</span>
<span className="sec-meta">{weekend.sessions.length}</span>
</div>
{weekend.sessions.length === 0 ? (
<div className="nav-section-meta">No sessions stored for this meeting.</div>
) : (
<div className="scroll-x">
<table className="data-table nav-table">
<thead>
<tr>
<th>Session</th>
<th className="hide-mobile">Type</th>
<th>Coverage</th>
<th>Source</th>
<th className="r">Open</th>
</tr>
</thead>
<tbody>
{weekend.sessions.map(({ session, source, datasets }) => {
const coverage = formatCoverageHint(datasets)
const isDefault = session.session_key === weekend.default_session_key
return (
<tr key={session.session_key} data-testid={`session-row-${session.session_key}`}>
<td>
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
{isDefault && <span className="nav-sub">default</span>}
<span className="nav-sub mono">{session.session_key}</span>
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{session.session_type}
</td>
<td>
<span className="mono" style={{ color: 'var(--text-2)' }}>
{coverage}
</span>
<SessionCoverageDots datasets={datasets} />
</td>
<td>{sessionSourceBadge(source)}</td>
<td className="r">
<button
type="button"
className="nav-action-btn nav-action-primary"
data-testid={`open-session-${session.session_key}`}
onClick={() => handleSelectSession(session.session_key)}
>
Race Hub
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}
// Re-export helpers used by tests
export { countRaceHubDatasets, formatCoverageHint } from '../lib/coverage'

View File

@@ -0,0 +1,108 @@
import { RACE_HUB_DATASETS, formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import {
CliCommands,
ingestMeetingCommands,
ingestSessionCommands,
} from './CliCommands'
import type { Weekend, WeekendSession } from '../types'
interface Props {
weekend: Weekend
}
export function MeetingDetailPanel({ weekend }: Props) {
const { meeting, sessions, source } = weekend
return (
<div className="dl-detail" data-testid="meeting-detail">
<div className="detail-header">
<div className="detail-header-row">
<span className="detail-title">{meeting.meeting_name}</span>
<SourceBadge source={source} label={source === 'local' ? 'Full' : undefined} />
</div>
<div className="detail-meta">
{meeting.country_name} · meeting_key {meeting.meeting_key}
</div>
<div className="detail-meta">
{sessions.length} session{sessions.length === 1 ? '' : 's'} stored locally
</div>
</div>
{sessions.length === 0 ? (
<div className="missing-notice">
No sessions ingested for this meeting. Run{' '}
<code>box-box --ingest-meeting {meeting.meeting_key}</code>
</div>
) : (
sessions.map((entry) => (
<SessionDetailBlock key={entry.session.session_key} entry={entry} />
))
)}
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Ingest Commands</span>
</div>
<CliCommands commands={ingestMeetingCommands(meeting.meeting_key)} />
</div>
</div>
)
}
function SessionDetailBlock({ entry }: { entry: WeekendSession }) {
const { session, source, datasets } = entry
const coverage = formatCoverageHint(datasets)
return (
<div className="session-detail-row" data-testid={`session-detail-${session.session_key}`}>
<div className="session-detail-head">
<SourceBadge source={source} />
<span>{session.session_name}</span>
<span className="session-detail-key mono">{session.session_key}</span>
<span className="session-detail-coverage mono">{coverage}</span>
<SessionCoverageDots datasets={datasets} />
</div>
<table className="data-table ds-detail-table">
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{RACE_HUB_DATASETS.map((key) => {
const info = datasets[key]
const available = info?.status === 'available'
return (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{key}
</td>
<td>
{info?.status === 'available' ? (
<span className="badge badge-local">Local</span>
) : info?.status === 'skipped' ? (
<span className="badge badge-none">N/A</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="session-cli">
<CliCommands commands={ingestSessionCommands(session.session_key)} />
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Link } from '@tanstack/react-router'
export function Nav() {
return (
<nav className="app-nav">
<Link to="/" className="nav-logo">
box<em>-</em>box
</Link>
<div className="nav-links">
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
Command
</Link>
<Link to="/live" activeProps={{ className: 'active' }}>
Live
</Link>
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/briefing" activeProps={{ className: 'active' }}>
Briefing
</Link>
</div>
<div className="nav-utility">
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
Admin
</Link>
</div>
</nav>
)
}

View File

@@ -0,0 +1,281 @@
import type { RaceHub } from '../types'
import { compareFinishPosition, formatDuration, formatGap, formatLapTime } from '../utils'
import { countRaceHubDatasets } from '../lib/coverage'
interface Props {
data: RaceHub
}
export function OverviewView({ data }: Props) {
const sortedResults = [...data.results].sort((a, b) =>
compareFinishPosition(a.position, b.position),
)
const winner = sortedResults[0]
const podium = sortedResults.filter((r) => r.position > 0).slice(0, 3)
const pole = data.starting_grid.find((g) => g.position === 1)
const fastest = pickFastestLap(data)
const latestWeather = data.weather.length > 0 ? data.weather[data.weather.length - 1] : null
const rcHighlights = data.race_control.slice(-3).reverse()
const coverage = countRaceHubDatasets(data.datasets)
const sessionType = (data.session?.session_type ?? '').toLowerCase()
const isRace = sessionType.includes('race')
const sessionLabel = isRace ? 'Race' : data.session?.session_type ?? 'Session'
return (
<div className="rh-overview" data-testid="rh-overview">
<div className="rh-stat-grid">
{winner && winner.position > 0 ? (
<StatCard
label={isRace ? 'Winner' : `${sessionLabel} P1`}
primary={winner.name_acronym || `#${winner.driver_number}`}
primaryColor={winner.team_colour ? `#${winner.team_colour}` : undefined}
secondary={winner.full_name}
tertiary={winner.team_name}
highlight={
isRace
? formatDuration(winner.duration)
: winner.duration
? formatDuration(winner.duration)
: ''
}
/>
) : (
<StatCard label={isRace ? 'Winner' : `${sessionLabel} P1`} placeholder />
)}
<PodiumCard podium={podium} />
{pole ? (
<StatCard
label={isRace ? 'Pole' : 'P1'}
primary={pole.name_acronym || `#${pole.driver_number}`}
primaryColor={pole.team_colour ? `#${pole.team_colour}` : undefined}
secondary={pole.full_name}
tertiary={pole.team_name}
highlight={pole.lap_duration ? formatLapTime(pole.lap_duration) : ''}
/>
) : (
<StatCard label={isRace ? 'Pole' : 'Grid'} placeholder />
)}
{fastest ? (
<StatCard
label="Fastest Lap"
primary={fastest.acronym}
primaryColor={fastest.colour ? `#${fastest.colour}` : undefined}
secondary={fastest.fullName}
tertiary={`Lap ${fastest.lap}`}
highlight={formatLapTime(fastest.time)}
/>
) : (
<StatCard label="Fastest Lap" placeholder />
)}
</div>
<div className="rh-overview-row">
<section className="rh-panel">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{latestWeather && (
<span className="sec-meta mono">{shortTime(latestWeather.date)}</span>
)}
</div>
{latestWeather ? (
<div className="rh-condition-strip" data-testid="rh-conditions">
<ConditionChip label="Air" value={`${latestWeather.air_temperature.toFixed(1)}°C`} />
<ConditionChip
label="Track"
value={`${latestWeather.track_temperature.toFixed(1)}°C`}
/>
<ConditionChip label="Humidity" value={`${latestWeather.humidity.toFixed(0)}%`} />
<ConditionChip
label="Wind"
value={`${latestWeather.wind_speed.toFixed(1)} m/s`}
/>
<ConditionChip
label="Rain"
value={latestWeather.rainfall > 0 ? 'Yes' : 'No'}
accent={latestWeather.rainfall > 0 ? 'wet' : undefined}
/>
</div>
) : (
<div className="rh-empty-line">No weather samples ingested.</div>
)}
</section>
<section className="rh-panel">
<div className="sec-header">
<span className="sec-title">Race Control · Latest</span>
<span className="sec-meta mono">{data.race_control.length}</span>
</div>
{rcHighlights.length === 0 ? (
<div className="rh-empty-line">No race-control messages.</div>
) : (
<ul className="rh-rc-list">
{rcHighlights.map((m, i) => (
<li key={i} className="rh-rc-row">
<span className="rh-rc-time mono">{shortTime(m.date)}</span>
<span className={`rh-rc-flag rh-rc-flag-${(m.flag || 'none').toLowerCase()}`}>
{m.flag || m.category || '—'}
</span>
<span className="rh-rc-msg">{m.message}</span>
</li>
))}
</ul>
)}
</section>
<section className="rh-panel">
<div className="sec-header">
<span className="sec-title">Local Coverage</span>
<span className="sec-meta mono">
{coverage.available}/{coverage.total}
</span>
</div>
<div className="rh-coverage-meter" aria-hidden="true">
<div
className="rh-coverage-fill"
style={{ width: `${(coverage.available / coverage.total) * 100}%` }}
/>
</div>
<div className="rh-empty-line" style={{ marginTop: 'var(--s2)' }}>
{coverage.available === coverage.total
? 'Every Race Hub dataset is local for this session.'
: `${coverage.total - coverage.available} dataset${
coverage.total - coverage.available === 1 ? '' : 's'
} not ingested yet — see Data Status tab.`}
</div>
</section>
</div>
</div>
)
}
interface StatCardProps {
label: string
primary?: string
primaryColor?: string
secondary?: string
tertiary?: string
highlight?: string
placeholder?: boolean
}
function StatCard({
label,
primary,
primaryColor,
secondary,
tertiary,
highlight,
placeholder,
}: StatCardProps) {
if (placeholder) {
return (
<div className="rh-stat-card rh-stat-empty">
<div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No data ingested</div>
</div>
)
}
return (
<div className="rh-stat-card">
<div className="rh-stat-label mono">{label}</div>
<div className="rh-stat-primary" style={primaryColor ? { color: primaryColor } : undefined}>
{primary}
</div>
{secondary && <div className="rh-stat-secondary">{secondary}</div>}
{tertiary && <div className="rh-stat-tertiary">{tertiary}</div>}
{highlight && <div className="rh-stat-highlight mono">{highlight}</div>}
</div>
)
}
function PodiumCard({ podium }: { podium: Array<{ name_acronym: string; team_colour: string; position: number; full_name: string; gap_to_leader: number | string | number[] | null; duration: number | number[] | null; driver_number: number }> }) {
if (podium.length === 0) {
return (
<div className="rh-stat-card rh-stat-empty">
<div className="rh-stat-label mono">Podium</div>
<div className="rh-stat-primary"></div>
<div className="rh-stat-secondary">No classified finishers</div>
</div>
)
}
return (
<div className="rh-stat-card">
<div className="rh-stat-label mono">Podium</div>
<ol className="rh-podium-list">
{podium.map((r) => (
<li key={r.driver_number} className={`rh-podium-row rh-podium-p${r.position}`}>
<span className="rh-podium-pos mono">P{r.position}</span>
<span
className="rh-podium-driver"
style={r.team_colour ? { color: `#${r.team_colour}` } : undefined}
>
{r.name_acronym || `#${r.driver_number}`}
</span>
<span className="rh-podium-gap mono">
{r.position === 1
? formatDuration(r.duration)
: formatGap(r.gap_to_leader)}
</span>
</li>
))}
</ol>
</div>
)
}
function ConditionChip({
label,
value,
accent,
}: {
label: string
value: string
accent?: 'wet'
}) {
return (
<div className={`rh-condition-chip${accent === 'wet' ? ' rh-condition-wet' : ''}`}>
<span className="rh-condition-label mono">{label}</span>
<span className="rh-condition-value">{value}</span>
</div>
)
}
function shortTime(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(11, 16)
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function pickFastestLap(
data: RaceHub,
): { lap: number; time: number; acronym: string; fullName: string; colour: string } | null {
const candidates = data.laps.filter(
(l) => l.lap_duration != null && l.lap_duration > 0 && !l.is_pit_out_lap,
)
if (candidates.length === 0) return null
let best = candidates[0]
for (const lap of candidates) {
if ((lap.lap_duration ?? 0) < (best.lap_duration ?? Infinity)) {
best = lap
}
}
const driverInfo = data.results.find((r) => r.driver_number === best.driver_number)
?? data.drivers.find((d) => d.driver_number === best.driver_number)
return {
lap: best.lap_number,
time: best.lap_duration ?? 0,
acronym:
('name_acronym' in (driverInfo ?? {}) ? (driverInfo as { name_acronym: string }).name_acronym : '')
|| `#${best.driver_number}`,
fullName:
('full_name' in (driverInfo ?? {}) ? (driverInfo as { full_name: string }).full_name : '') || '',
colour:
('team_colour' in (driverInfo ?? {}) ? (driverInfo as { team_colour: string }).team_colour : '') || '',
}
}

View File

@@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchNews } from '../api'
import { timeAgo } from '../utils'
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
'bbc-f1': 'BBC Sport',
'autosport-f1': 'Autosport',
'racefans-f1': 'RaceFans',
'guardian-f1': 'Guardian',
'racer-f1': 'RACER',
'f1-youtube': 'F1 YouTube',
}
export function PaddockBriefing() {
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
const preview = news?.slice(0, 5) ?? []
return (
<section className="cc-briefing" data-testid="paddock-briefing">
<div className="sec-header">
<span className="sec-title">
Paddock Briefing
{unreadCount > 0 && (
<span className="cc-brief-unread">{unreadCount}</span>
)}
</span>
<Link to="/briefing" className="sec-action mono">
View all
</Link>
</div>
{isLoading && <div className="briefing-state loading-state">loading</div>}
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
{!isLoading && !isError && preview.length === 0 && (
<div className="briefing-state">
No items. Run <code>box-box --ingest-news</code> to populate.
</div>
)}
{preview.length > 0 && (
<div className="cc-brief-strip" role="list">
{preview.map((item) => (
<Link
key={item.url}
to="/briefing"
className={`cc-brief-item${item.read_at ? ' is-read' : ''}`}
role="listitem"
>
<div className="cc-brief-item-meta mono">
<span className="cc-brief-source">
{SOURCE_DISPLAY[item.source] ?? item.source}
</span>
<span className="cc-brief-age">
{timeAgo(item.published_at ?? item.fetched_at)}
</span>
</div>
<span className="cc-brief-title">{item.title}</span>
</Link>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,91 @@
import type { RaceControlMessage } from '../types'
import { rcFlagClass } from '../lib/live'
interface Props {
messages: RaceControlMessage[]
}
function formatEventTime(date: string): string {
if (!date) return '—'
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date
return parsed.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function eventLabel(message: RaceControlMessage): string {
return message.flag || message.category || 'Message'
}
function eventClass(message: RaceControlMessage): string {
const flagClass = rcFlagClass(message.flag ?? '')
if (flagClass) return flagClass
const category = (message.category ?? '').toLowerCase()
const text = `${message.message ?? ''} ${message.category ?? ''}`.toLowerCase()
if (category.includes('safety') || text.includes('safety car')) return 'rc-flag-sc'
if (category === 'drs' || text.includes('drs')) return 'rc-flag-drs'
if (text.includes('virtual safety car')) return 'rc-flag-vsc'
if (text.includes('red flag')) return 'rc-flag-red'
if (text.includes('yellow')) return 'rc-flag-yellow'
if (text.includes('green light') || text.includes('green flag')) return 'rc-flag-green'
if (text.includes('chequered') || text.includes('checkered')) return 'rc-flag-chequered'
return 'rc-flag-other'
}
export function RaceControlView({ messages }: Props) {
if (messages.length === 0) {
return (
<div className="missing-notice">
Race control messages not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const rows = [...messages].sort((a, b) => a.date.localeCompare(b.date))
return (
<div className="scroll-x" data-testid="race-control-view">
<table className="data-table" style={{ minWidth: 620 }}>
<thead>
<tr>
<th>Time</th>
<th className="c">Lap</th>
<th>Event</th>
<th className="c hide-mobile">Driver</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{rows.map((message, index) => {
const visualClass = eventClass(message)
return (
<tr className={`race-control-row ${visualClass}`} key={`${message.date}-${index}`}>
<td className="mono rc-time-cell">
{formatEventTime(message.date)}
</td>
<td className="c mono">{message.lap_number ?? '—'}</td>
<td>
<span className={`rc-event-pill rc-flag ${visualClass}`}>{eventLabel(message)}</span>
{message.scope && (
<span className="rc-scope">
{message.scope.toLowerCase()}
</span>
)}
</td>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
<td className="rc-message-cell">{message.message || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import type { Meeting, Session, RaceHub } from '../types'
import { formatDate } from '../utils'
interface Props {
meeting?: Meeting
session?: Session
source: RaceHub['source']
}
function SourceBadge({ source }: { source: RaceHub['source'] }) {
if (source === 'local') return <span className="badge badge-local">Local</span>
if (source === 'partial') return <span className="badge badge-partial">Partial</span>
return <span className="badge badge-none">No data</span>
}
export function RaceHubHeader({ meeting, session, source }: Props) {
const meetingName = meeting?.meeting_name ?? 'Unknown Meeting'
const sessionName = session?.session_name ?? 'Unknown Session'
const dateStr = formatDate(session?.date_start ?? meeting?.date_start)
const location = meeting ? `${meeting.location} · ${meeting.country_name}` : null
return (
<div className="rh-header">
<div className="rh-title-group">
<div className="rh-meeting">{meetingName}</div>
<div className="rh-session">{sessionName}</div>
<div className="rh-meta">
{dateStr && <span className="rh-meta-item">{dateStr}</span>}
{location && <span className="rh-meta-item" style={{ opacity: 0.6 }}>·</span>}
{location && <span className="rh-meta-item">{location}</span>}
</div>
</div>
<div style={{ flexShrink: 0, paddingTop: 2 }}>
<SourceBadge source={source} />
</div>
</div>
)
}

View File

@@ -0,0 +1,377 @@
import { useState, useMemo, useRef, useCallback } from 'react'
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
interface Props {
data: {
results: EnrichedResult[]
starting_grid: EnrichedGrid[]
positions: PositionSample[]
laps: Lap[]
datasets: Record<string, any>
race_control?: any[]
pit_stops?: any[]
session?: Session
}
}
export function RaceStoryCanvas({ data }: Props) {
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
const hasPositions = datasets['positions']?.status === 'available'
const [scrubTime, setScrubTime] = useState<number | null>(null)
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0
let chartContent = null
let displayResults = results
if (hasChartData) {
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
const tRange = Math.max(tMax - tMin, 1)
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) {
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange,
pos: p.position,
})
}
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) {
samples.push({ t: 1, pos: samples[samples.length - 1].pos })
}
}
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
if (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) {
const dt = samples[i+1].t - samples[i].t
if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
}
}
return null
}
if (scrubTime !== null) {
const currentPos = new Map<number, number>()
for (const [dNum, samples] of byDriver.entries()) {
const pos = getInterpPos(samples, scrubTime)
if (pos !== null) {
currentPos.set(dNum, pos)
}
}
displayResults = [...results].sort((a, b) => {
const posA = currentPos.get(a.driver_number) ?? 999
const posB = currentPos.get(b.driver_number) ?? 999
return posA - posB
})
}
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const W = 640
const H = 180
const PL = 40
const PR = 48
const PT = 8
const PB = 20
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10
for (const lap of winnerLaps) {
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
if (t >= 0 && t <= 1) {
lapTicks.push({ lap: lap.lap_number, t })
}
}
}
// Safety Car / VSC periods
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
for (const msg of rc) {
const t = new Date(msg.date).getTime()
const m = msg.message?.toUpperCase() || ''
const cat = msg.category?.toUpperCase() || ''
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'VSC' }
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'SC' }
} else if (m.includes('TRACK CLEAR') || m.includes('CLEAR')) {
if (activeSC) {
scPeriods.push({ start: activeSC.start, end: t, type: activeSC.type })
activeSC = null
}
}
}
if (activeSC) {
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
}
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
setScrubTime(t)
}
chartContent = (
<div className="rs-chart-container scroll-x" data-testid="position-chart">
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
aria-label="Position evolution chart"
>
{scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1
const x1 = toX(Math.max(0, startT))
const x2 = toX(Math.min(1, endT))
if (x2 <= PL || x1 >= W - PR) return null
return (
<rect
key={`sc-${i}`}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={sc.type === 'SC' ? 'rgba(255, 153, 0, 0.15)' : 'rgba(255, 204, 0, 0.1)'}
/>
)
})}
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
stroke="var(--border)"
strokeWidth={0.5}
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
>
P{pos}
</text>
</g>
))}
{lapTicks.map(tick => (
<g key={`lap-${tick.lap}`}>
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
L{tick.lap}
</text>
</g>
))}
{Array.from(byDriver.entries()).map(([dNum, samples]) => {
const colour = colorByDriver.get(dNum)
const color = colour ? `#${colour}` : '#888'
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
return (
<g
key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)}
onMouseLeave={() => setHoverDriver(null)}
>
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={isHovered ? 3 : 2}
strokeLinejoin="round"
strokeLinecap="round"
className="rs-driver-line"
pathLength={1}
/>
{driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange
if (t < 0 || t > 1) return null
const pos = getInterpPos(samples, t)
if (pos === null) return null
return (
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
strokeWidth={2}
className="rs-pit-dot"
/>
)
})}
{last && (
<text
x={toX(last.t) + 6}
y={toY(last.pos) + 4}
fill={color}
fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)"
fontWeight={700}
style={{ cursor: 'default' }}
>
{acronymByDriver.get(dNum) ?? dNum}
</text>
)}
</g>
)
})}
{scrubTime !== null && (
<line
x1={toX(scrubTime)}
x2={toX(scrubTime)}
y1={PT}
y2={H - PB}
stroke="var(--text)"
strokeWidth={1}
strokeDasharray="4 2"
className="rs-playhead"
style={{ pointerEvents: 'none' }}
/>
)}
<rect
x={PL}
y={PT}
width={plotW}
height={plotH}
fill="transparent"
onPointerMove={handlePointerMove}
onPointerLeave={() => setScrubTime(null)}
style={{ cursor: 'crosshair', touchAction: 'none' }}
/>
</svg>
</div>
)
}
return (
<div className="race-story-canvas">
{hasChartData ? (
chartContent
) : (
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>.
</div>
)}
{displayResults.length > 0 && (
<div className="rs-field-list">
{displayResults.map((r, i) => {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
let currentPoints: number | string = r.points
if (scrubTime !== null) {
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint')
const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1]
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
}
return (
<div
key={r.driver_number}
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
onMouseEnter={() => setHoverDriver(r.driver_number)}
onMouseLeave={() => setHoverDriver(null)}
>
<div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}>
{currentPos}
</div>
<div className="rs-driver-cell">
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/>
<div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
<span className="rs-driver-team">{r.team_name}</span>
</div>
</div>
</div>
<div className="rs-driver-right">
<div className="rs-metric">
<span>
<span className={gridDeltaClass(currentPos, gridPos)}>
{gridDelta(currentPos, gridPos)}
</span>
</span>
<span className="rs-metric-label">Grid</span>
</div>
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div>
<div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{currentPoints}
</span>
<span className="rs-metric-label">Pts</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { RACE_HUB_DATASETS } from '../lib/coverage'
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
export function SessionCoverageDots({ datasets }: Props) {
return (
<span className="coverage-dots" aria-hidden="true">
{RACE_HUB_DATASETS.map((key) => {
const available = datasets[key]?.status === 'available' || datasets[key]?.status === 'skipped'
return <span key={key} className={`coverage-dot ${available ? 'on' : 'off'}`} />
})}
</span>
)
}

View File

@@ -0,0 +1,28 @@
type Source = 'local' | 'partial' | 'none'
interface Props {
source: Source
label?: string
}
export function SourceBadge({ source, label }: Props) {
switch (source) {
case 'local':
return <span className="badge badge-local">{label ?? 'Local'}</span>
case 'partial':
return <span className="badge badge-partial">{label ?? 'Partial'}</span>
default:
return <span className="badge badge-none">{label ?? 'None'}</span>
}
}
export function weekendStatusLabel(source: Source): string {
switch (source) {
case 'local':
return 'Full'
case 'partial':
return 'Partial'
default:
return 'Missing'
}
}

View File

@@ -0,0 +1,235 @@
import type { EnrichedResult, Stint, PitStop } from '../types'
import { compareFinishPosition } from '../utils'
const COMPOUND_COLORS: Record<string, string> = {
SOFT: '#e8002d',
MEDIUM: '#ffd600',
HARD: '#e8e8e4',
INTERMEDIATE: '#39b54a',
WET: '#0067ff',
}
function compoundColor(c: string): string {
return COMPOUND_COLORS[c.toUpperCase()] ?? '#666'
}
function compoundInitial(c: string): string {
const abbr: Record<string, string> = {
SOFT: 'S',
MEDIUM: 'M',
HARD: 'H',
INTERMEDIATE: 'I',
WET: 'W',
}
return abbr[c.toUpperCase()] ?? c[0] ?? '?'
}
interface Props {
results: EnrichedResult[]
stints: Stint[]
pit_stops: PitStop[]
hasStints: boolean
}
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
if (!hasStints) {
return (
<div>
<div className="analysis-notice">
<strong>Stints not available.</strong> This session does not have ingested
tyre compound and stint ranges in <code>/api/v1/race-hub</code>. Strategy
charts require per-driver stints: compound, lap_start, lap_end.
</div>
{results.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Laps Completed</span>
<span className="sec-meta">from results hint at pit count</span>
</div>
<table className="data-table" style={{ maxWidth: 360 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="r">Laps</th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.driver_number}>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{r.position}
</td>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="r mono" style={{ color: r.number_of_laps > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.number_of_laps > 0 ? r.number_of_laps : '—'}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)
}
const sortedDrivers = [...results].sort((a, b) => {
const cmp = compareFinishPosition(a.position, b.position)
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
})
const totalLaps = Math.max(
...stints.map((s) => s.lap_end),
...results.map((r) => r.number_of_laps),
1
)
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const BAR_W = SVG_W - LEFT - RIGHT
const SVG_H = sortedDrivers.length * ROW_H + 8
const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W
const stintW = (s: Stint) =>
Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W)
const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter(
(c) => c in COMPOUND_COLORS
)
return (
<div data-testid="strategy-chart">
<div className="scroll-x">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
style={{ width: '100%', minWidth: 280, maxWidth: SVG_W, display: 'block' }}
role="img"
aria-label="Race strategy stint chart"
>
{sortedDrivers.map((driver, i) => {
const rowY = i * ROW_H
const color = driver.team_colour ? `#${driver.team_colour}` : '#888'
const dStints = stints.filter((s) => s.driver_number === driver.driver_number)
const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number)
return (
<g key={driver.driver_number} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={color}
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={10}
>
{driver.name_acronym}
</text>
{dStints.map((stint, si) => {
const x = lapX(stint.lap_start)
const w = stintW(stint)
const fill = compoundColor(stint.compound)
return (
<g key={si}>
<rect x={x} y={BAR_Y} width={w} height={BAR_H} fill={fill} rx={1.5} />
{w > 18 && (
<text
x={x + w / 2}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="middle"
fill="#111"
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={8}
>
{compoundInitial(stint.compound)}
</text>
)}
</g>
)
})}
{dPits.map((pit, pi) => {
const x = lapX(pit.lap_number)
return (
<line
key={pi}
x1={x}
x2={x}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
stroke="var(--text)"
strokeWidth={1.5}
opacity={0.7}
/>
)
})}
</g>
)
})}
</svg>
</div>
<div
style={{
display: 'flex',
gap: 'var(--s4)',
flexWrap: 'wrap',
marginTop: 'var(--s4)',
fontSize: 11,
color: 'var(--text-3)',
alignItems: 'center',
}}
>
{usedCompounds.map((c) => (
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
width: 10,
height: 10,
background: COMPOUND_COLORS[c],
borderRadius: 2,
display: 'inline-block',
border: c === 'HARD' ? '1px solid #555' : undefined,
}}
/>
{c.charAt(0) + c.slice(1).toLowerCase()}
</span>
))}
{pit_stops.length > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginLeft: 4 }}>
<span
style={{
width: 1,
height: 12,
background: 'var(--text)',
display: 'inline-block',
opacity: 0.7,
}}
/>
Pit stop
</span>
)}
<span style={{ marginLeft: 'auto', fontFamily: 'var(--f-mono)', fontSize: 10 }}>
{totalLaps} laps
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
export type Tab =
| 'overview'
| 'race_story'
| 'strategy'
| 'lap_data'
| 'conditions'
| 'race_control'
| 'data_status'
const TABS: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' },
{ id: 'strategy', label: 'Strategy' },
{ id: 'lap_data', label: 'Lap Data' },
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },
{ id: 'data_status', label: 'Data Status' },
]
interface Props {
active: Tab
onChange: (tab: Tab) => void
}
export function TabBar({ active, onChange }: Props) {
return (
<div className="tab-bar" role="tablist">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,98 @@
import type { WeatherSample } from '../types'
interface Props {
weather: WeatherSample[]
}
function avg(values: number[]): number {
if (values.length === 0) return 0
return values.reduce((sum, val) => sum + val, 0) / values.length
}
function formatNumber(value: number, digits = 1): string {
return Number.isFinite(value) ? value.toFixed(digits) : '—'
}
function formatTime(date: string): string {
if (!date) return '—'
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date
return parsed.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
})
}
export function WeatherView({ weather }: Props) {
if (weather.length === 0) {
return (
<div className="missing-notice">
Weather samples not ingested. Run <code>box-box --ingest-session &lt;key&gt;</code>{' '}
to load this dataset.
</div>
)
}
const rows = [...weather].sort((a, b) => a.date.localeCompare(b.date))
const latest = rows[rows.length - 1]
const rainfallSamples = rows.filter((sample) => sample.rainfall > 0).length
return (
<div data-testid="weather-view">
<table className="data-table" style={{ maxWidth: 520, marginBottom: 'var(--s5)' }}>
<thead>
<tr>
<th>Summary</th>
<th className="r">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Latest sample</td>
<td className="r">{formatTime(latest.date)}</td>
</tr>
<tr>
<td>Avg air / track</td>
<td className="r">
{formatNumber(avg(rows.map((sample) => sample.air_temperature)))}C /{' '}
{formatNumber(avg(rows.map((sample) => sample.track_temperature)))}C
</td>
</tr>
<tr>
<td>Rain samples</td>
<td className="r">{rainfallSamples}</td>
</tr>
</tbody>
</table>
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 560 }}>
<thead>
<tr>
<th>Time</th>
<th className="r">Air</th>
<th className="r">Track</th>
<th className="r hide-mobile">Humidity</th>
<th className="r hide-mobile">Rain</th>
<th className="r hide-mobile">Wind</th>
</tr>
</thead>
<tbody>
{rows.slice(-12).map((sample) => (
<tr key={sample.date}>
<td className="mono" style={{ color: 'var(--text-3)' }}>
{formatTime(sample.date)}
</td>
<td className="r">{formatNumber(sample.air_temperature)}C</td>
<td className="r">{formatNumber(sample.track_temperature)}C</td>
<td className="r hide-mobile">{formatNumber(sample.humidity, 0)}%</td>
<td className="r hide-mobile">{formatNumber(sample.rainfall)}</td>
<td className="r hide-mobile">{formatNumber(sample.wind_speed)} m/s</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
interface Props {
currentMeetingKey?: number
currentSessionKey?: number
onClose: () => void
}
export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose }: Props) {
const navigate = useNavigate()
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
const [year, setYear] = useState<number | null>(null)
const [openMeetingKey, setOpenMeetingKey] = useState<number | null>(
currentMeetingKey ?? null,
)
useEffect(() => {
if (year == null && seasonsQuery.data?.length) {
setYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, year])
const meetingsQuery = useQuery({
queryKey: ['meetings', year],
queryFn: () => fetchLocalMeetings(year!),
enabled: year != null,
})
const weekendQuery = useQuery({
queryKey: ['weekend', openMeetingKey],
queryFn: () => fetchWeekend(openMeetingKey!),
enabled: openMeetingKey != null,
})
const seasons = seasonsQuery.data ?? []
const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data
function openSession(sessionKey: number) {
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
onClose()
}
return (
<div className="rh-switcher" data-testid="rh-switcher">
<div className="rh-switcher-head">
<span className="sec-title">Switch Weekend</span>
<div className="rh-switcher-years">
{seasons.map((y) => (
<button
key={y}
type="button"
className={`rh-switcher-year${y === year ? ' active' : ''}`}
onClick={() => {
setYear(y)
setOpenMeetingKey(null)
}}
>
{y}
</button>
))}
</div>
<button type="button" className="rh-switcher-close" onClick={onClose}>
Close
</button>
</div>
{meetingsQuery.isLoading && (
<div className="rh-switcher-empty">loading meetings</div>
)}
{!meetingsQuery.isLoading && meetings.length === 0 && (
<div className="rh-switcher-empty">No meetings ingested for {year}.</div>
)}
{meetings.length > 0 && (
<div className="rh-switcher-grid">
{meetings.map((m) => {
const expanded = m.meeting_key === openMeetingKey
const isCurrent = m.meeting_key === currentMeetingKey
return (
<div
key={m.meeting_key}
className={`rh-switcher-mtg${expanded ? ' expanded' : ''}${isCurrent ? ' current' : ''}`}
>
<button
type="button"
className="rh-switcher-mtg-head"
aria-expanded={expanded}
onClick={() =>
setOpenMeetingKey((prev) => (prev === m.meeting_key ? null : m.meeting_key))
}
data-testid={`rh-switcher-meeting-${m.meeting_key}`}
>
<span className="rh-switcher-decal mono">{countryDecal(m)}</span>
<span className="rh-switcher-mtg-name">{m.meeting_name}</span>
<span className="rh-switcher-mtg-meta mono">{formatGpDateRange(m)}</span>
</button>
{expanded && (
<div className="rh-switcher-sessions">
{weekendQuery.isLoading && (
<div className="rh-switcher-empty">loading sessions</div>
)}
{weekend && weekend.meeting_key === m.meeting_key &&
weekend.sessions.map(({ session, source, datasets }) => {
const active = session.session_key === currentSessionKey
return (
<button
key={session.session_key}
type="button"
className={`rh-switcher-session${active ? ' active' : ''}`}
onClick={() => openSession(session.session_key)}
data-testid={`rh-switcher-session-${session.session_key}`}
>
<span className="rh-switcher-sess-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className="rh-switcher-sess-name">{session.session_name}</span>
<span className="rh-switcher-sess-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</span>
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,38 @@
import type { LiveRCMessage } from '../../types'
import { latestRaceControl, rcFlagClass } from '../../lib/live'
interface Props {
messages: LiveRCMessage[]
}
export function RaceControlFeed({ messages }: Props) {
const latest = latestRaceControl(messages)
return (
<section className="live-rc">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
</div>
{latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div>
) : (
<div className="live-rc-list live-rc-scroll">
{latest.map((message, index) => (
<div className="live-rc-row" key={`${message.Time}-${message.Message}-${index}`}>
<span className="rc-time">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
: message.Category && message.Category !== 'Other'
? <span className="rc-category">{message.Category}</span>
: null
}
<span className="rc-message">{message.Message}</span>
</div>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,50 @@
import type { LiveStreamData } from '../../types'
import { extrapolateClock, trackStatusClass, trackStatusLabel } from '../../lib/live'
interface Props {
isLive: boolean
snapshot: LiveStreamData
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number
}
export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
const status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : ''
const weather = snapshot.Weather
const hasWeather = weather && (weather.AirTemp > 0 || weather.TrackTemp > 0)
return (
<section className="live-banner">
<div className="live-banner-row">
<div className="live-banner-main">
<span className={`live-conn live-conn-${connection}`}>{connection}</span>
<div>
<h1>{session?.MeetingName || 'Live Timing'}</h1>
<p>
{[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'}
</p>
</div>
</div>
<div className="live-banner-meta">
{status && <span className={`track-status ${trackStatusClass(snapshot.TrackStatus)}`}>{status}</span>}
<span className="mono">
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
{clock && <span className="mono">{clock}</span>}
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
</div>
</div>
{hasWeather && (
<div className="live-weather-strip">
<span>{weather.AirTemp.toFixed(0)}° air</span>
<span>{weather.TrackTemp.toFixed(0)}° track</span>
{weather.Humidity > 0 && <span>{weather.Humidity.toFixed(0)}% humidity</span>}
{weather.WindSpeed > 0 && <span>{weather.WindSpeed.toFixed(1)} m/s</span>}
{weather.Rainfall && <span className="badge badge-wet">WET</span>}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,96 @@
import { teamColor } from '../../utils'
import type { LiveStreamData } from '../../types'
import {
driverCode,
positionDelta,
positionDeltaClass,
sortLiveTimingRows,
tyreClass,
tyreLabel,
} from '../../lib/live'
interface Props {
snapshot: LiveStreamData
}
function posClass(pos: number): string {
if (pos === 1) return 'pos-p1'
if (pos === 2) return 'pos-p2'
if (pos === 3) return 'pos-p3'
return 'pos-n'
}
export function TimingTower({ snapshot }: Props) {
const rows = sortLiveTimingRows(snapshot)
if (rows.length === 0) {
return (
<div className="missing-notice">
Live timing is connected, but no driver timing rows have arrived yet.
</div>
)
}
return (
<div className="scroll-x">
<table className="data-table live-tower" style={{ minWidth: 480 }}>
<thead>
<tr>
<th>Pos</th>
<th>Δ</th>
<th>Driver</th>
<th>Tyre</th>
<th>Last Lap</th>
<th>Gap</th>
<th className="hide-mobile">Best</th>
<th className="hide-mobile r">Laps</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const driver = row.Driver
const delta = positionDelta(driver)
const deltaClass = positionDeltaClass(driver)
return (
<tr
key={row.RacingNumber}
className={[
driver.InPit ? 'in-pit' : '',
driver.PitOut ? 'pit-out' : '',
driver.Retired ? 'retired' : '',
].filter(Boolean).join(' ')}
>
<td className={`mono ${posClass(row.Position)}`}>{row.Position}</td>
<td className={`pos-delta${deltaClass ? ` ${deltaClass}` : ''}`}>{delta}</td>
<td>
<div className="drv-cell">
<div className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span>
<span className="drv-num">{row.RacingNumber}</span>
{driver.InPit && <span className="badge badge-pit">PIT</span>}
{driver.PitOut && !driver.InPit && <span className="badge badge-pit">OUT</span>}
{driver.Retired && <span className="badge badge-out">RET</span>}
{driver.KnockedOut && <span className="badge badge-knocked">KO</span>}
{driver.Cutoff && !driver.KnockedOut && <span className="badge badge-cutoff">CUT</span>}
{driver.OnFlyingLap && <span className="badge badge-flying">FL</span>}
</div>
</td>
<td>
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
</td>
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">{driver.GapToLeader || driver.Interval || '-'}</td>
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
{driver.BestLapTime || '-'}
</td>
<td className="hide-mobile mono r">{driver.NumberOfLaps || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import type { DatasetInfo, Weekend, WeekendSession } from '../types'
export const RACE_HUB_DATASETS = [
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
] as const
export type RaceHubDatasetKey = (typeof RACE_HUB_DATASETS)[number]
export function countRaceHubDatasets(datasets: Record<string, DatasetInfo>): { available: number; total: number } {
const total = RACE_HUB_DATASETS.length
const available = RACE_HUB_DATASETS.filter((key) => {
const status = datasets[key]?.status
return status === 'available' || status === 'skipped'
}).length
return { available, total }
}
export function formatCoverageHint(datasets: Record<string, DatasetInfo>): string {
const { available, total } = countRaceHubDatasets(datasets)
return `${available}/${total}`
}
export function isSessionComplete(datasets: Record<string, DatasetInfo>): boolean {
const { available, total } = countRaceHubDatasets(datasets)
return available === total
}
export function sessionTypeAbbrev(sessionType: string, sessionName: string): string {
const type = sessionType.toLowerCase()
const name = sessionName.toLowerCase()
if (type.includes('race') || name === 'race') return 'R'
if (type.includes('qualifying') || name.startsWith('q')) return 'Q'
if (type.includes('sprint')) return 'S'
if (name.includes('fp1') || name.includes('practice 1')) return 'FP1'
if (name.includes('fp2') || name.includes('practice 2')) return 'FP2'
if (name.includes('fp3') || name.includes('practice 3')) return 'FP3'
return sessionName.slice(0, 3).toUpperCase()
}
export function countWeekendStats(weekends: (Weekend | undefined)[]) {
let full = 0
let partial = 0
let missing = 0
for (const weekend of weekends) {
if (!weekend || weekend.sessions.length === 0) {
missing++
continue
}
switch (weekend.source) {
case 'local':
full++
break
case 'partial':
partial++
break
default:
missing++
}
}
return { full, partial, missing, total: weekends.length }
}
export function sessionIconClass(session: WeekendSession): string {
if (session.source === 'none') return 'si-missing'
if (isSessionComplete(session.datasets)) return 'si-full'
return 'si-partial'
}

View File

@@ -0,0 +1,110 @@
import type { Meeting } from '../types'
const COUNTRY_ACCENTS: Record<string, string> = {
ITA: '#1f7a4d',
GBR: '#3a6cf5',
USA: '#d62828',
MON: '#d61a3e',
ESP: '#e7a90b',
FRA: '#2353d6',
GER: '#cdc7c2',
DEU: '#cdc7c2',
BEL: '#e0a01b',
NLD: '#ef7c1a',
NED: '#ef7c1a',
HUN: '#3f8a4b',
AUT: '#c61f1f',
AZE: '#1f7eaf',
CAN: '#c8202c',
AUS: '#1b6b86',
JPN: '#c8202c',
SGP: '#c8202c',
BRA: '#2fa84f',
BHR: '#a8132b',
BRN: '#a8132b',
SAU: '#0a7a3b',
KSA: '#0a7a3b',
ARE: '#4d6b2a',
UAE: '#4d6b2a',
QAT: '#7a1e3e',
CHN: '#c8202c',
MEX: '#1f7a4d',
}
const DEFAULT_ACCENT = '#9aa0a6'
export function countryAccent(meeting: Meeting | undefined | null): string {
if (!meeting) return DEFAULT_ACCENT
const code = meeting.country_code?.toUpperCase()
if (code && COUNTRY_ACCENTS[code]) return COUNTRY_ACCENTS[code]
return DEFAULT_ACCENT
}
export function countryDecal(meeting: Meeting | undefined | null): string {
const code = meeting?.country_code?.toUpperCase()
if (code && code.length >= 2) return code.slice(0, 3)
const name = meeting?.country_name ?? ''
return name.slice(0, 3).toUpperCase() || '—'
}
export function countryFlag(meeting: Meeting | undefined | null): string {
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
const code = meeting?.country_code?.toUpperCase()
const iso2 = code ? CODE3_TO_2[code] : ''
if (!iso2) return ''
const offset = 0x1f1e6 - 65
return String.fromCodePoint(iso2.charCodeAt(0) + offset, iso2.charCodeAt(1) + offset)
}
const CODE3_TO_2: Record<string, string> = {
AUS: 'AU',
AUT: 'AT',
AZE: 'AZ',
BEL: 'BE',
BHR: 'BH',
BRN: 'BH',
BRA: 'BR',
CAN: 'CA',
CHN: 'CN',
DEU: 'DE',
GER: 'DE',
ESP: 'ES',
FRA: 'FR',
GBR: 'GB',
HUN: 'HU',
ITA: 'IT',
JPN: 'JP',
MEX: 'MX',
MON: 'MC',
NED: 'NL',
NLD: 'NL',
QAT: 'QA',
SAU: 'SA',
KSA: 'SA',
SGP: 'SG',
UAE: 'AE',
ARE: 'AE',
USA: 'US',
}
export function formatGpDateRange(meeting: Meeting | undefined | null): string {
if (!meeting) return ''
const start = meeting.date_start?.slice(0, 10)
const end = meeting.date_end?.slice(0, 10)
if (!start && !end) return ''
const fmt = (iso: string): string => {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
if (start && end && start !== end) {
const left = fmt(start)
const right = fmt(end)
const year = end.slice(0, 4)
return `${left} ${right} ${year}`
}
if (start) {
return `${fmt(start)} ${start.slice(0, 4)}`
}
return end ? `${fmt(end)} ${end.slice(0, 4)}` : ''
}

162
frontend/src/lib/live.ts Normal file
View File

@@ -0,0 +1,162 @@
import type {
LiveDriverData,
LiveDriverInfo,
LiveRCMessage,
LiveStateResponse,
LiveStreamData,
LiveTyreData,
} from '../types'
export interface LiveTimingRow {
RacingNumber: string
Position: number
Driver: LiveDriverData
Info?: LiveDriverInfo
Tyre?: LiveTyreData
}
const TRACK_STATUS_LABELS: Record<string, string> = {
'1': 'GREEN',
'2': 'YELLOW',
'4': 'SC',
'5': 'RED',
'6': 'VSC',
}
export function parseLiveStateEvent(data: string): LiveStateResponse | null {
try {
const parsed = JSON.parse(data) as LiveStateResponse
return typeof parsed === 'object' && parsed !== null ? parsed : null
} catch {
return null
}
}
export function sortLiveTimingRows(snapshot: LiveStreamData | null | undefined): LiveTimingRow[] {
if (!snapshot) return []
const rowsByNumber = new Map<string, LiveTimingRow>()
for (const [number, driver] of Object.entries(snapshot.Drivers ?? {})) {
rowsByNumber.set(number, {
RacingNumber: driver.RacingNumber || number,
Position: driver.Position || 0,
Driver: { ...driver, RacingNumber: driver.RacingNumber || number },
Info: snapshot.DriverInfo?.[number],
Tyre: snapshot.Tyres?.[number],
})
}
for (const [number, info] of Object.entries(snapshot.DriverInfo ?? {})) {
if (!rowsByNumber.has(number)) {
rowsByNumber.set(number, {
RacingNumber: info.RacingNumber || number,
Position: 0,
Driver: {
RacingNumber: info.RacingNumber || number,
Position: 0,
} as LiveDriverData,
Info: info,
Tyre: snapshot.Tyres?.[number],
})
}
}
const rows = [...rowsByNumber.values()]
rows.sort((a, b) => {
if (a.Position > 0 && b.Position > 0) return a.Position - b.Position
if (a.Position > 0) return -1
if (b.Position > 0) return 1
const aBest = a.Driver.BestLapTime || ''
const bBest = b.Driver.BestLapTime || ''
if (aBest && bBest) return aBest.localeCompare(bBest)
if (aBest) return -1
if (bBest) return 1
return Number(a.RacingNumber) - Number(b.RacingNumber)
})
return rows.map((row, index) => ({
...row,
Position: row.Position || index + 1,
}))
}
export function driverCode(row: LiveTimingRow): string {
return row.Info?.Tla || row.RacingNumber
}
export function trackStatusLabel(status: string): string {
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
}
export function trackStatusClass(status: string): string {
return `track-${trackStatusLabel(status).toLowerCase()}`
}
export function positionDelta(driver: LiveDriverData): string {
if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return ''
return driver.PrevPosition > driver.Position ? '▲' : '▼'
}
export function positionDeltaClass(driver: LiveDriverData): string {
if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return ''
return driver.PrevPosition > driver.Position ? 'pos-gain' : 'pos-loss'
}
const RC_FLAG_CSS: Record<string, string> = {
GREEN: 'rc-flag-green',
YELLOW: 'rc-flag-yellow',
'DOUBLE YELLOW': 'rc-flag-yellow',
RED: 'rc-flag-red',
BLUE: 'rc-flag-blue',
BLACK: 'rc-flag-black',
'BLACK AND ORANGE': 'rc-flag-black',
'BLACK AND WHITE': 'rc-flag-black',
SC: 'rc-flag-sc',
'SAFETY CAR': 'rc-flag-sc',
VSC: 'rc-flag-vsc',
'VIRTUAL SAFETY CAR': 'rc-flag-vsc',
CHEQUERED: 'rc-flag-chequered',
CHECKERED: 'rc-flag-chequered',
}
export function rcFlagClass(flag: string): string {
if (!flag) return ''
return RC_FLAG_CSS[flag.toUpperCase()] ?? ''
}
export function tyreLabel(tyre: LiveTyreData | undefined): string {
if (!tyre) return '?'
const compound = tyre.Compound?.charAt(0) || '?'
return `${compound} +${tyre.Age || 0}`
}
export function tyreClass(tyre: LiveTyreData | undefined): string {
if (!tyre?.Compound) return 'tyre-unknown'
const compound = tyre.Compound.toLowerCase()
return `tyre-${compound === 'intermediate' ? 'inter' : compound}`
}
export function latestRaceControl(messages: LiveRCMessage[], limit = 10): LiveRCMessage[] {
return [...(messages ?? [])].reverse().slice(0, limit)
}
export function extrapolateClock(clock: string, refTime: string, extrapolating: boolean, now = Date.now()): string {
if (!clock || !extrapolating || !refTime) return clock || ''
const parts = clock.split(':').map(Number)
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) return clock
const refMs = new Date(refTime).getTime()
if (!Number.isFinite(refMs)) return clock
const totalSeconds = parts[0] * 3600 + parts[1] * 60 + parts[2]
const elapsed = Math.max(0, (now - refMs) / 1000)
const remaining = Math.max(0, totalSeconds - elapsed)
const hours = Math.floor(remaining / 3600)
const minutes = Math.floor((remaining % 3600) / 60)
const seconds = Math.floor(remaining % 60)
return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':')
}

View File

@@ -0,0 +1,168 @@
import type { Meeting, Session } from '../types'
const DEFAULT_SESSION_DURATION_MS = 3 * 60 * 60 * 1000
export function parseScheduleTime(value: string): Date | null {
if (!value) return null
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) return new Date(parsed)
const dateOnly = Date.parse(value.slice(0, 10))
return Number.isNaN(dateOnly) ? null : new Date(dateOnly)
}
export function meetingStartTime(meeting: Meeting): Date | null {
return parseScheduleTime(meeting.date_start)
}
export function meetingEndTime(meeting: Meeting): Date | null {
const end = parseScheduleTime(meeting.date_end)
if (end) return end
const start = meetingStartTime(meeting)
return start ? new Date(start.getTime() + 72 * 60 * 60 * 1000) : null
}
export function sessionStartTime(session: Session): Date | null {
return parseScheduleTime(session.date_start)
}
export function sessionEndTime(session: Session): Date | null {
const end = parseScheduleTime(session.date_end)
if (end) return end
const start = sessionStartTime(session)
return start ? new Date(start.getTime() + DEFAULT_SESSION_DURATION_MS) : null
}
export function sortSessionsByStart(sessions: Session[]): Session[] {
return [...sessions].sort((a, b) => {
const left = sessionStartTime(a)?.getTime() ?? 0
const right = sessionStartTime(b)?.getTime() ?? 0
if (left !== right) return left - right
return a.date_start.localeCompare(b.date_start)
})
}
export function currentMeeting(meetings: Meeting[], now: Date): Meeting | null {
let selected: Meeting | null = null
let latest = 0
for (const meeting of meetings) {
const start = meetingStartTime(meeting)
if (!start || start > now) continue
const end = meetingEndTime(meeting)
if (!end || now > new Date(end.getTime() + 24 * 60 * 60 * 1000)) continue
const startMs = start.getTime()
if (!selected || startMs > latest) {
selected = meeting
latest = startMs
}
}
return selected
}
export function nextUpcomingMeeting(meetings: Meeting[], now: Date): Meeting | null {
for (const meeting of meetings) {
const start = meetingStartTime(meeting)
if (start && start > now) return meeting
}
return null
}
export function mostRecentPastMeeting(meetings: Meeting[], now: Date): Meeting | null {
let selected: Meeting | null = null
let latest = 0
for (const meeting of meetings) {
const start = meetingStartTime(meeting)
if (!start || start > now) continue
const startMs = start.getTime()
if (!selected || startMs > latest) {
selected = meeting
latest = startMs
}
}
return selected
}
export function pickFocusMeeting(meetings: Meeting[], now: Date): Meeting | null {
return (
currentMeeting(meetings, now) ??
nextUpcomingMeeting(meetings, now) ??
mostRecentPastMeeting(meetings, now) ??
meetings[0] ??
null
)
}
export function meetingHasStarted(meeting: Meeting, now: Date): boolean {
const start = meetingStartTime(meeting)
return start != null && now >= start
}
export function currentAndNextSession(
sessions: Session[],
now: Date,
): { current: Session | null; next: Session | null } {
const sorted = sortSessionsByStart(sessions)
for (const session of sorted) {
const start = sessionStartTime(session)
const end = sessionEndTime(session)
if (!start || !end) continue
if (now >= start && now < end) {
return { current: session, next: null }
}
if (now < start) {
return { current: null, next: session }
}
}
return { current: null, next: null }
}
export function formatCountdown(target: Date, now: Date): string {
const diffMs = Math.max(0, target.getTime() - now.getTime())
const totalSeconds = Math.floor(diffMs / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
return `${days}d ${String(hours).padStart(2, '0')}h ${String(mins).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`
}
export function formatSessionScheduleTime(value: string): string {
const date = parseScheduleTime(value)
if (!date) return '—'
return date.toLocaleString('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {
if (currentMeeting([meeting], now)) return 'current'
if (nextUpcomingMeeting([meeting], now)) return 'next'
if (mostRecentPastMeeting([meeting], now)) return 'recent'
return 'fallback'
}
export function focusMeetingLabel(kind: FocusMeetingKind): string {
switch (kind) {
case 'current':
return 'Current Weekend'
case 'next':
return 'Next Weekend'
case 'recent':
return 'Recent Local Weekend'
default:
return 'Weekend'
}
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './router'
import './styles/app.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,488 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
type Category = 'all' | 'official' | 'news' | 'video'
const PAGE_SIZE = 16
const CATEGORY_LABELS: Record<Category, string> = {
all: 'All',
official: 'Official',
news: 'News',
video: 'Video',
}
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
'bbc-f1': 'BBC Sport',
'autosport-f1': 'Autosport',
'racefans-f1': 'RaceFans',
'guardian-f1': 'Guardian',
'racer-f1': 'RACER',
'f1-youtube': 'F1 YouTube',
}
function displaySource(id: string): string {
return SOURCE_DISPLAY[id] ?? id
}
function categoryOf(item: NewsItem): Category {
const c = (item.category ?? '').toLowerCase()
if (c === 'official') return 'official'
if (c === 'video') return 'video'
return 'news'
}
function getYouTubeVideoId(url: string): string | null {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
const match = url.match(regExp)
return match && match[2].length === 11 ? match[2] : null
}
function CategoryTabs({
active,
counts,
onChange,
}: {
active: Category
counts: Record<Category, number>
onChange: (c: Category) => void
}) {
return (
<div className="bp-cats" role="tablist" aria-label="Briefing categories">
{(Object.keys(CATEGORY_LABELS) as Category[]).map((cat) => (
<button
key={cat}
role="tab"
aria-selected={active === cat}
className={`bp-cat${active === cat ? ' active' : ''}`}
onClick={() => onChange(cat)}
>
{CATEGORY_LABELS[cat]}
{counts[cat] > 0 && (
<span className="bp-cat-count">{counts[cat]}</span>
)}
</button>
))}
</div>
)
}
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
if (!url || failed) {
return (
<div className="bp-card-img bp-card-img-fallback" aria-hidden="true">
<span className="bp-card-img-initial">{initial}</span>
</div>
)
}
return (
<div className="bp-card-img-wrap">
<img
className="bp-card-img"
src={url}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
</div>
)
}
function BriefingCard({
item,
isActive,
onSelect,
}: {
item: NewsItem
isActive: boolean
onSelect: (item: NewsItem) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
return (
<article
className={`bp-card${isActive ? ' bp-card-active' : ''}${isRead ? ' bp-card-read' : ''}`}
onClick={() => onSelect(item)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onSelect(item)}
aria-pressed={isActive}
>
<OGImage url={item.og_image_url} title={item.title} />
{isVideo && <span className="bp-video-badge"> Video</span>}
<div className="bp-card-body">
<div className="bp-card-meta mono">
<span className="bp-card-source">{displaySource(item.source)}</span>
<span className="bp-card-age">{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div>
<h3 className="bp-card-title">{item.title}</h3>
{(item.og_description || item.summary) && (
<p className="bp-card-desc">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
</div>
</article>
)
}
function ReaderPanel({
item,
onClose,
}: {
item: NewsItem | null
onClose: () => void
}) {
const panelRef = useRef<HTMLDivElement>(null)
const [article, setArticle] = useState<ArticleContent | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
// Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
return
}
if (categoryOf(item) === 'video') {
setArticle(null)
setLoading(false)
setError(null)
return
}
setLoading(true)
setError(null)
setArticle(null)
fetchNewsArticle(item.url)
.then((data) => { setArticle(data); setLoading(false) })
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : ''])
// Scroll panel to top when item changes
useEffect(() => {
panelRef.current?.scrollTo({ top: 0 })
}, [item?.url])
const isOpen = item !== null
return (
<>
{isOpen && (
<div className="bp-reader-backdrop" onClick={onClose} aria-hidden="true" />
)}
<aside
ref={panelRef}
className={`bp-reader${isOpen ? ' open' : ''}`}
aria-label="Article reader"
aria-hidden={!isOpen}
>
{item && (
<>
<div className="bp-reader-toolbar">
<div className="bp-reader-toolbar-meta mono">
<span>{displaySource(item.source)}</span>
<span className="bp-reader-dot">·</span>
<span>{timeAgo(item.published_at ?? item.fetched_at)}</span>
</div>
<div className="bp-reader-toolbar-actions">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-open"
title="Open in browser"
>
</a>
<button
className="bp-reader-close"
onClick={onClose}
aria-label="Close reader"
>
</button>
</div>
</div>
{categoryOf(item) === 'video' ? (
<div className="bp-reader-video-container">
<iframe
className="bp-reader-video-iframe"
src={`https://www.youtube.com/embed/${getYouTubeVideoId(item.url)}?autoplay=1&rel=0`}
title={item.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</div>
) : (
(article?.image_url || item.og_image_url) && (
<div className="bp-reader-hero">
<img
src={article?.image_url ?? item.og_image_url}
alt=""
loading="lazy"
/>
</div>
)
)}
<div className="bp-reader-content">
<h1 className="bp-reader-title">
{article?.title ?? item.title}
</h1>
{article?.byline && (
<div className="bp-reader-byline mono">{article.byline}</div>
)}
{categoryOf(item) === 'video' && (
<div className="bp-reader-video-desc">
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback" style={{ marginBottom: '16px' }}>
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
style={{ fontSize: '13px', fontWeight: 600 }}
>
Open in YouTube
</a>
</div>
)}
{loading && (
<div className="bp-reader-loading">Loading article</div>
)}
{error && !loading && (
<div className="bp-reader-error">
<p>Could not load full article.</p>
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
>
Open in browser
</a>
</div>
)}
{article && !loading && article.content && (
<div
className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }}
/>
)}
{article && !loading && !article.content && (
<div className="bp-reader-error">
<p>No article content found.</p>
{(item.og_description || item.summary) && (
<p className="bp-reader-fallback">
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="bp-reader-ext-link"
>
Open in browser
</a>
</div>
)}
</div>
</>
)}
</aside>
</>
)
}
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const [page, setPage] = useState(1)
const queryClient = useQueryClient()
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
)
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount)
const pageStart = (safePage - 1) * PAGE_SIZE
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
const counts: Record<Category, number> = {
all: allNews.length,
official: allNews.filter((i) => categoryOf(i) === 'official').length,
news: allNews.filter((i) => categoryOf(i) === 'news').length,
video: allNews.filter((i) => categoryOf(i) === 'video').length,
}
const handleSelect = useCallback(
(item: NewsItem) => {
setSelectedItem((prev) => (prev?.url === item.url ? null : item))
if (!item.read_at) {
markNewsRead(item.url).then(() => {
queryClient.setQueryData<NewsItem[]>(['news'], (old) =>
old?.map((n) =>
n.url === item.url ? { ...n, read_at: new Date().toISOString() } : n,
),
)
})
}
},
[queryClient],
)
const handleClose = useCallback(() => setSelectedItem(null), [])
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
const handlePageChange = useCallback(
(nextPage: number) => {
setPage(Math.min(Math.max(nextPage, 1), pageCount))
setSelectedItem(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
},
[pageCount],
)
const unreadCount = allNews.filter((i) => !i.read_at).length
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
const showPagination = filtered.length > PAGE_SIZE
return (
<div className="bp-page" data-testid="briefing-page">
<div className="bp-topbar">
<span className="bp-topbar-label mono">box-box · paddock briefing</span>
<span className="bp-topbar-meta mono">
{unreadCount > 0 ? `${unreadCount} unread` : 'all read'}
</span>
</div>
{isLoading && <div className="loading-state">loading briefing</div>}
{isError && <div className="error-box">Failed to load paddock briefing.</div>}
{!isLoading && !isError && (
<>
<CategoryTabs
active={activeCategory}
counts={counts}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
/>
{filtered.length === 0 ? (
<div className="bp-empty">
No {activeCategory !== 'all' ? activeCategory : ''} items available.
Run <code>box-box --ingest-news</code> to refresh feeds.
</div>
) : (
<>
{showPagination && (
<div className="bp-pagination bp-pagination-top">
<span className="bp-pagination-meta mono">
{pageStart + 1}-{pageEnd} of {filtered.length}
</span>
<div className="bp-pagination-actions">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
</div>
)}
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{pagedItems.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
{showPagination && (
<div className="bp-pagination bp-pagination-bottom">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
)}
</>
)}
</>
)}
<ReaderPanel item={selectedItem} onClose={handleClose} />
</div>
)
}

View File

@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchWeekend } from '../api'
import { countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
currentAndNextSession,
focusMeetingKind,
formatCountdown,
formatSessionScheduleTime,
meetingHasStarted,
pickFocusMeeting,
sessionEndTime,
sessionStartTime,
sortSessionsByStart,
} from '../lib/schedule'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { PaddockBriefing } from '../components/PaddockBriefing'
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
const start = sessionStartTime(session)
const end = sessionEndTime(session)
if (start && end && now >= start && now < end) return 'live'
if (start && now >= start) return 'done'
return 'upcoming'
}
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past'
return 'future'
}
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function CommandCenterPage() {
const [now, setNow] = useState(() => Date.now())
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: latestSeason != null,
})
const seasonMeetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const localMeetings = meetingsQuery.data ?? []
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const meetings = localMeetings
const weekendQueries = useQueries({
queries: meetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: meetings.length > 0,
staleTime: 60_000,
})),
})
const liveQuery = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
})
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
meetings.forEach((meeting, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(meeting.meeting_key, data)
})
return map
}, [meetings, weekendQueries])
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
const meetingStats = countWeekendStats(weekendList)
const focusMeeting = pickFocusMeeting(meetings, nowDate)
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
const focusSessions: Session[] = focusWeekend
? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session))
: []
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
const analysisSession = pickAnalysisSession(focusWeekend)
const analysisSessionKey =
analysisSession?.session.session_key ??
focusWeekend?.default_session_key ??
focusWeekend?.sessions[0]?.session.session_key
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div>
}
if (seasonsQuery.isError) {
return (
<div className="page error-box">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="cc-page cc-empty" data-testid="command-center-empty">
<div className="cc-empty-band">
<span className="cc-empty-eyebrow mono">box-box · command center</span>
<h1 className="cc-empty-title">No local data yet</h1>
<p className="cc-empty-sub">
Ingest a race weekend from the CLI to populate this screen with live status, next-session
countdowns, and analysis links.
</p>
</div>
<div className="cc-empty-actions">
<Link to="/live" className="cc-action cc-action-live">
<span className="cc-action-label">Live Timing</span>
<span className="cc-action-meta">Standby</span>
</Link>
<Link to="/admin" className="cc-action">
<span className="cc-action-label">Admin · Data Health</span>
<span className="cc-action-meta">Ingestion guidance</span>
</Link>
</div>
</div>
)
}
const liveActive = liveQuery.data?.is_live === true
const statusKind: WeekendStatusKind = liveActive
? 'live'
: focusKind === 'current'
? 'current'
: focusKind === 'next'
? 'next'
: focusKind === 'recent'
? 'recent'
: 'fallback'
const accent = countryAccent(focusMeeting ?? null)
const decal = countryDecal(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return (
<div className="cc-page" data-testid="command-center" style={accentStyle}>
<div className="cc-topbar">
<span className="cc-topbar-label mono">box-box · command center</span>
<span className="cc-topbar-meta mono">
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
</span>
<span className="cc-live-pill" data-testid="cc-live-status">
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
{liveActive ? 'Live session active' : 'No live session'}
</span>
</div>
{!focusMeeting && (
<div className="missing-notice">
No meetings ingested for {latestSeason}. Run{' '}
<code>box-box --ingest-year {latestSeason}</code>
</div>
)}
{focusMeeting && (
<section className="cc-weekend-band" data-testid="cc-focus">
<div className="cc-band-accent" aria-hidden="true" />
<div className="cc-band-body">
<div className="cc-band-row">
<span className="cc-band-decal mono">{decal}</span>
<div className="cc-band-titles">
<div className="cc-band-eyebrow mono">
<WeekendKindLabel kind={statusKind} />
</div>
<h1 className="cc-band-name">{focusMeeting.meeting_name}</h1>
<div className="cc-band-sub mono">
{[focusMeeting.location, focusMeeting.circuit_short_name]
.filter(Boolean)
.join(' · ')}
</div>
<div className="cc-band-sub mono cc-band-dates">{formatGpDateRange(focusMeeting)}</div>
</div>
<CountdownBlock
liveActive={liveActive}
currentSession={currentSession}
nextSession={nextSession}
meeting={focusMeeting}
now={nowDate}
/>
</div>
</div>
</section>
)}
{focusMeeting && (
<div className="cc-actions-row" data-testid="cc-actions">
<Link
to="/live"
className={`cc-pri-action ${liveActive ? 'is-live' : ''}`}
data-testid="cc-action-live"
>
<span className="cc-pri-label">Watch Live</span>
<span className="cc-pri-meta mono">{liveActive ? 'Feed active' : 'Standby'}</span>
</Link>
<Link
to="/race-hub"
search={analysisSessionKey ? { session_key: analysisSessionKey } : {}}
className="cc-pri-action"
data-testid="cc-action-race-hub"
>
<span className="cc-pri-label">Open Analysis</span>
<span className="cc-pri-meta mono">
{analysisSession
? `${analysisSession.session.session_name} · session ${analysisSession.session.session_key}`
: 'Pick a session'}
</span>
</Link>
{nextSession && sessionStartTime(nextSession) && (
<a
href="#cc-schedule"
className="cc-pri-action"
data-testid="cc-action-schedule"
onClick={(e) => {
e.preventDefault()
document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' })
}}
>
<span className="cc-pri-label">Schedule</span>
<span className="cc-pri-meta mono">
next: {nextSession.session_name}
</span>
</a>
)}
</div>
)}
{focusWeekend && focusWeekend.sessions.length > 0 && (
<section className="cc-schedule" id="cc-schedule">
<div className="sec-header">
<span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekend.sessions.length} sessions</span>
</div>
<div className="cc-session-strip" role="list">
{focusWeekend.sessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`cc-session-card cc-status-${status}${isNext ? ' is-next' : ''}${
isCurrent ? ' is-current' : ''
}`}
data-testid={`cc-session-${session.session_key}`}
role="listitem"
>
<div className="cc-session-card-head">
<span className="cc-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className={`cc-session-status cc-status-pill-${status}`}>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</span>
</div>
<div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono">
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)}
</div>
</Link>
)
})}
</div>
</section>
)}
{seasonMeetings.length > 0 && (
<section className="cc-season-calendar" data-testid="cc-season-calendar">
<div className="sec-header">
<span className="sec-title">Season Calendar</span>
<span className="sec-meta mono">
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
</span>
</div>
<div className="cc-calendar-grid" role="list">
{seasonMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = pickAnalysisSession(weekend)
const targetKey = target?.session.session_key ?? weekend?.default_session_key
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
const cardAccent = countryAccent(meeting)
return (
<Link
key={meeting.meeting_key}
to="/race-hub"
search={targetKey ? { session_key: targetKey } : {}}
className={`cc-calendar-card cc-calendar-${status}`}
data-testid={`cc-calendar-${meeting.meeting_key}`}
role="listitem"
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
>
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
</div>
<div className="cc-calendar-id">
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
</div>
<div className="cc-calendar-copy">
<div className="cc-calendar-name">{meeting.meeting_name}</div>
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
</div>
</Link>
)
})}
</div>
{seasonMeetingsQuery.isError && (
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
)}
</section>
)}
<PaddockBriefing />
</div>
)
}
function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
switch (kind) {
case 'live':
return <span className="cc-kind cc-kind-live"> Live now</span>
case 'current':
return <span className="cc-kind cc-kind-current">Current weekend</span>
case 'next':
return <span className="cc-kind cc-kind-next">Next weekend</span>
case 'recent':
return <span className="cc-kind cc-kind-recent">Recent weekend</span>
default:
return <span className="cc-kind">Weekend</span>
}
}
interface CountdownBlockProps {
liveActive: boolean
currentSession: Session | null
nextSession: Session | null
meeting: Meeting
now: Date
}
function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) {
if (liveActive) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">SignalR</div>
<div className="cc-cd-value cc-cd-live">LIVE</div>
<div className="cc-cd-sub mono">{currentSession?.session_name ?? 'Feed connected'}</div>
</div>
)
}
if (currentSession) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">On Track</div>
<div className="cc-cd-value cc-cd-current">{currentSession.session_name}</div>
<div className="cc-cd-sub mono">In session</div>
</div>
)
}
if (nextSession && sessionStartTime(nextSession)) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">Next · {nextSession.session_name}</div>
<div className="cc-cd-value mono">{formatCountdown(sessionStartTime(nextSession)!, now)}</div>
<div className="cc-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
</div>
)
}
if (meetingHasStarted(meeting, now)) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">Status</div>
<div className="cc-cd-value cc-cd-done">Complete</div>
<div className="cc-cd-sub mono">Weekend finished</div>
</div>
)
}
return null
}

View File

@@ -0,0 +1,332 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import {
countWeekendStats,
formatCoverageHint,
sessionIconClass,
sessionTypeAbbrev,
} from '../lib/coverage'
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
import type { Meeting, Weekend } from '../types'
function formatMeetingDate(meeting: Meeting): string {
const start = meeting.date_start?.slice(0, 10)
if (!start) return '—'
const d = new Date(start)
if (Number.isNaN(d.getTime())) return start
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
export function DataLibraryPage() {
const [selectedYear, setSelectedYear] = useState<number | null>(null)
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(null)
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear],
queryFn: () => fetchLocalMeetings(selectedYear!),
enabled: selectedYear != null,
})
const meetings = meetingsQuery.data ?? []
const weekendQueries = useQueries({
queries: meetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: meetings.length > 0,
staleTime: 60_000,
})),
})
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
meetings.forEach((meeting, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(meeting.meeting_key, data)
})
return map
}, [meetings, weekendQueries])
const stats = countWeekendStats(weekendQueries.map((q) => q.data))
useEffect(() => {
if (seasonsQuery.data?.length && selectedYear == null) {
setSelectedYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, selectedYear])
useEffect(() => {
if (meetings.length === 0) {
setSelectedMeetingKey(null)
return
}
if (selectedMeetingKey == null || !meetings.some((m) => m.meeting_key === selectedMeetingKey)) {
setSelectedMeetingKey(meetings[0].meeting_key)
}
}, [meetings, selectedMeetingKey])
const selectedWeekend = selectedMeetingKey != null ? weekendsByKey.get(selectedMeetingKey) : undefined
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading local data library</div>
}
if (seasonsQuery.isError) {
return (
<div className="page error-box">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="page" data-testid="data-library-empty">
<div className="dl-page-header">
<span className="dl-page-eyebrow mono">box-box · admin</span>
<h1 className="dl-page-title">Data Health</h1>
<span className="dl-page-sub mono">Local SQLite domain store · ingestion guidance</span>
</div>
<div className="empty-state">
<div className="empty-state-title">No ingested seasons yet</div>
<div className="empty-state-desc">
Ingest a season or session from the CLI, then return here to inspect coverage.
</div>
</div>
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Get Started</span>
</div>
<CliCommands
commands={[
{ comment: '# Discover season meetings and sessions', cmd: 'box-box --ingest-year 2025' },
{ comment: '# Then ingest a full weekend or single session', cmd: 'box-box --ingest-meeting <meeting_key>' },
]}
/>
</div>
</div>
)
}
return (
<div className="dl-page" data-testid="data-library">
<div className="dl-page-banner">
<div className="dl-banner-titles">
<span className="dl-page-eyebrow mono">box-box · admin</span>
<h1 className="dl-page-title">Data Health</h1>
</div>
<div className="dl-banner-stats mono">
<span>
<em>{seasons.length}</em> season{seasons.length === 1 ? '' : 's'}
</span>
<span>
<em className="dl-stat-full">{stats.full}</em> full
</span>
<span>
<em className="dl-stat-partial">{stats.partial}</em> partial
</span>
<span>
<em>{stats.missing}</em> missing
</span>
</div>
</div>
<div className="dl-layout">
<aside className="dl-nav">
<div>
<div className="sec-header">
<span className="sec-title">Seasons</span>
</div>
<div className="season-list" role="listbox" aria-label="Season">
{seasons.map((year) => (
<button
key={year}
type="button"
role="option"
aria-selected={year === selectedYear}
className={`season-row ${year === selectedYear ? 'active' : ''}`}
onClick={() => {
setSelectedYear(year)
setSelectedMeetingKey(null)
}}
>
<span>{year}</span>
</button>
))}
</div>
</div>
{selectedYear != null && meetings.length > 0 && (
<div className="dl-stats">
<div className="dl-stat">
<span className="dl-stat-label">Full</span>
<span className="dl-stat-val dl-stat-full">{stats.full}</span>
</div>
<div className="dl-stat">
<span className="dl-stat-label">Partial</span>
<span className="dl-stat-val dl-stat-partial">{stats.partial}</span>
</div>
<div className="dl-stat">
<span className="dl-stat-label">Missing</span>
<span className="dl-stat-val">{stats.missing}</span>
</div>
<div className="dl-stat">
<span className="dl-stat-label">Total</span>
<span className="dl-stat-val">{stats.total}</span>
</div>
</div>
)}
{selectedYear != null && (
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Season Ingest</span>
</div>
<CliCommands commands={ingestYearCommands(selectedYear)} />
</div>
)}
</aside>
<div className="dl-content">
<div className="dl-content-header">
<span className="dl-content-title">{selectedYear} Season</span>
{meetings.length > 0 && (
<span className="dl-content-meta">
{meetings.length} meeting{meetings.length === 1 ? '' : 's'}
{!weekendsLoading && (
<>
{' '}
· {stats.full} complete · {stats.partial} partial
</>
)}
</span>
)}
</div>
{meetingsQuery.isLoading && (
<div className="loading-state">loading meetings</div>
)}
{meetingsQuery.isError && (
<div className="error-box">
{meetingsQuery.error instanceof Error
? meetingsQuery.error.message
: 'Failed to load meetings'}
</div>
)}
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
<div className="missing-notice">
No meetings discovered for {selectedYear}. Run{' '}
<code>box-box --ingest-year {selectedYear}</code>
</div>
)}
{meetings.length > 0 && (
<div className="dl-content-body">
<div className="dl-round-scroll">
<table className="data-table rounds-table">
<thead>
<tr>
<th className="c" style={{ width: 36 }}>
#
</th>
<th>Weekend</th>
<th className="hide-mobile">Date</th>
<th>Status</th>
<th className="hide-mobile">Sessions</th>
<th className="hide-mobile">Coverage</th>
</tr>
</thead>
<tbody>
{meetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const selected = meeting.meeting_key === selectedMeetingKey
const source = weekend?.source ?? 'none'
return (
<tr
key={meeting.meeting_key}
className={selected ? 'dl-row-selected' : ''}
data-testid={`dl-meeting-${meeting.meeting_key}`}
onClick={() => setSelectedMeetingKey(meeting.meeting_key)}
>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{index + 1}
</td>
<td>
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
<span className="nav-sub mono">key {meeting.meeting_key}</span>
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{formatMeetingDate(meeting)}
</td>
<td>
<SourceBadge source={source} label={weekendStatusLabel(source)} />
</td>
<td className="hide-mobile">
{weekend ? (
<div className="session-icons">
{weekend.sessions.map((entry) => (
<span
key={entry.session.session_key}
className={`session-icon ${sessionIconClass(entry)}`}
title={`${entry.session.session_name} (${formatCoverageHint(entry.datasets)})`}
>
{sessionTypeAbbrev(entry.session.session_type, entry.session.session_name)}
</span>
))}
</div>
) : (
<span className="mono" style={{ color: 'var(--text-3)' }}>
</span>
)}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
{weekend && weekend.sessions.length > 0
? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full`
: '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="dl-detail-wrap">
{weekendsLoading && selectedWeekend == null && (
<div className="loading-state">loading weekend details</div>
)}
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
<div className="missing-notice">Could not load weekend details.</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="dl-footer-link">
<Link to="/" className="dl-footer-back">
Command Center
</Link>
<Link to="/race-hub" search={{}}>
Open Race Hub
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import type { LiveStreamData } from '../types'
import { parseLiveStateEvent } from '../lib/live'
import { SessionBanner } from '../components/live/SessionBanner'
import { TimingTower } from '../components/live/TimingTower'
import { RaceControlFeed } from '../components/live/RaceControlFeed'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export function LiveTimingPage() {
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now())
const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'],
queryFn: fetchLiveState,
staleTime: 5_000,
})
useEffect(() => {
if (!data) return
setIsLive(data.is_live)
setSnapshot(data.data)
}, [data])
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
useEffect(() => {
if (!('EventSource' in window)) {
setStreamStatus('error')
return
}
let cancelled = false
const events = new EventSource('/api/v1/live/stream')
setStreamStatus('connecting')
events.onopen = () => {
if (!cancelled) setStreamStatus('connected')
}
events.addEventListener('snapshot', (event) => {
const state = parseLiveStateEvent(event.data)
if (!state || cancelled) return
setIsLive(state.is_live)
setSnapshot(state.data)
setStreamStatus('connected')
})
events.addEventListener('heartbeat', () => {
if (!cancelled) setStreamStatus('connected')
})
events.onerror = () => {
if (!cancelled) setStreamStatus('disconnected')
}
return () => {
cancelled = true
events.close()
}
}, [])
return (
<div className="page live-page" data-testid="live-page">
{isError && (
<div className="error-box">
{error instanceof Error ? error.message : 'Failed to load live timing state'}
</div>
)}
{streamStatus === 'disconnected' && snapshot && (
<div className="live-status-strip live-status-warn">
Stream disconnected showing last received snapshot
</div>
)}
{isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div>
)}
{!isLoading && !snapshot && (
<div className="empty-state" data-testid="live-empty">
<div className="live-empty-status">
<span className={`live-conn live-conn-${streamStatus}`}>{streamStatus}</span>
</div>
<div className="empty-state-title">No live session active</div>
<div className="empty-state-desc">
No timing data in the current snapshot. The feed will update automatically when an F1 session goes live.
</div>
</div>
)}
{snapshot && (
<>
<SessionBanner isLive={isLive} snapshot={snapshot} connection={streamStatus} now={now} />
<div className="live-columns">
<div className="live-tower-col">
<div className="sec-header">
<span className="sec-title">Timing Tower</span>
</div>
<TimingTower snapshot={snapshot} />
</div>
<div className="live-rc-col">
<RaceControlFeed messages={snapshot.RCMessages ?? []} />
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,356 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import {
fetchLocalMeetings,
fetchRaceHub,
fetchSeasons,
fetchWeekend,
} from '../api'
import { DatasetStrip } from '../components/DatasetStrip'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import { TabBar, type Tab } from '../components/TabBar'
import { DatasetStatusView } from '../components/DatasetStatusView'
import { StrategyView } from '../components/StrategyView'
import { LapsView } from '../components/LapsView'
import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
import { WeekendSwitcher } from '../components/WeekendSwitcher'
import { SourceBadge } from '../components/SourceBadge'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import {
formatSessionScheduleTime,
pickFocusMeeting,
sortSessionsByStart,
} from '../lib/schedule'
import type { Weekend, WeekendSession } from '../types'
interface Props {
sessionKey: number
}
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
if (!weekend) return undefined
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
if (race) return race
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (qual) return qual
return pool[0]
}
export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [switcherOpen, setSwitcherOpen] = useState(false)
// ─── Auto-redirect when no session_key is supplied ───
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: sessionKey === 0,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason],
queryFn: () => fetchLocalMeetings(latestSeason!),
enabled: sessionKey === 0 && latestSeason != null,
})
const focusMeeting = useMemo(() => {
if (sessionKey !== 0 || !meetingsQuery.data) return null
return pickFocusMeeting(meetingsQuery.data, new Date())
}, [sessionKey, meetingsQuery.data])
const fallbackWeekendQuery = useQuery({
queryKey: ['weekend', focusMeeting?.meeting_key],
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
enabled: sessionKey === 0 && focusMeeting != null,
})
useEffect(() => {
if (sessionKey !== 0) return
const weekend = fallbackWeekendQuery.data
if (!weekend) return
const target = pickAnalysisSession(weekend)?.session.session_key
?? weekend.default_session_key
?? weekend.sessions[0]?.session.session_key
if (target) {
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
}
}, [sessionKey, fallbackWeekendQuery.data, navigate])
// ─── Active session payload ───
const raceHubQuery = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey),
enabled: sessionKey > 0,
staleTime: 30_000,
})
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
const weekendQuery = useQuery({
queryKey: ['weekend', meetingKey],
queryFn: () => fetchWeekend(meetingKey!),
enabled: meetingKey != null && meetingKey > 0,
staleTime: 60_000,
})
const data = raceHubQuery.data
const weekend = weekendQuery.data
const accent = countryAccent(data?.meeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
if (sessionKey === 0) {
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
<div className="rh-empty-band">
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
<h1 className="rh-empty-title">No local sessions yet</h1>
<p className="rh-empty-sub">
The Race Hub reads from local ingest only. Once a weekend is ingested
it will open here automatically.
</p>
<div className="rh-empty-actions">
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
<a href="/" className="rh-empty-action">Back to Command Center</a>
</div>
</div>
</div>
)
}
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">resolving latest local weekend</div>
</div>
)
}
// ─── Loading / error for the requested session_key ───
if (raceHubQuery.isLoading) {
return (
<div className="rh-page" style={accentStyle}>
<div className="loading-state">loading session {sessionKey}</div>
</div>
)
}
if (raceHubQuery.isError || !data) {
return (
<div className="rh-page" style={accentStyle}>
<div className="error-box">
{raceHubQuery.error instanceof Error
? raceHubQuery.error.message
: `Failed to load session ${sessionKey}.`}
</div>
</div>
)
}
const decal = countryDecal(data.meeting ?? null)
const sessions = weekend ? sortSessionsByStart(weekend.sessions.map((w) => w.session)) : []
const sessionMeta = weekend
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
: {}
const activeSessionMeta = sessionMeta[sessionKey]
return (
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
{/* Topbar */}
<div className="rh-topbar">
<span className="rh-topbar-label mono">
box-box · race hub
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
</span>
<span className="rh-topbar-spacer" />
<SourceBadge source={data.source} />
<button
type="button"
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
onClick={() => setSwitcherOpen((v) => !v)}
aria-expanded={switcherOpen}
data-testid="rh-switch-weekend"
>
{switcherOpen ? 'Close' : 'Switch Weekend'}
</button>
</div>
{switcherOpen && (
<WeekendSwitcher
currentMeetingKey={meetingKey}
currentSessionKey={sessionKey}
onClose={() => setSwitcherOpen(false)}
/>
)}
{/* GP Identity band */}
{data.meeting && (
<section className="rh-identity" data-testid="rh-identity">
<div className="rh-identity-accent" aria-hidden="true" />
<div className="rh-identity-body">
<span className="rh-identity-decal mono">{decal}</span>
<div className="rh-identity-titles">
<h1 className="rh-identity-name">{data.meeting.meeting_name}</h1>
<div className="rh-identity-sub mono">
{[data.meeting.location, data.meeting.circuit_short_name]
.filter(Boolean)
.join(' · ')}
</div>
<div className="rh-identity-sub mono rh-identity-dates">
{formatGpDateRange(data.meeting)}
</div>
</div>
</div>
</section>
)}
{/* Session rail */}
{sessions.length > 0 && (
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
{sessions.map((session) => {
const meta = sessionMeta[session.session_key]
const active = session.session_key === sessionKey
return (
<button
key={session.session_key}
type="button"
className={`rh-session-chip${active ? ' active' : ''}`}
onClick={() =>
navigate({
to: '/race-hub',
search: { session_key: session.session_key },
})
}
aria-current={active ? 'page' : undefined}
data-testid={`rh-session-${session.session_key}`}
>
<span className="rh-session-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</span>
<span className="rh-session-name">{session.session_name}</span>
<span className="rh-session-time mono">
{formatSessionScheduleTime(session.date_start)}
</span>
{meta && (
<span className="rh-session-cov mono">
<span
className={`cc-cov-dot cc-cov-${meta.source}`}
aria-hidden="true"
/>
{formatCoverageHint(meta.datasets)}
</span>
)}
</button>
)
})}
</nav>
)}
{/* Active session sub-bar */}
{data.session && (
<div className="rh-active-bar" data-testid="rh-active-bar">
<span className="rh-active-name">{data.session.session_name}</span>
<span className="rh-active-meta mono">
{formatSessionScheduleTime(data.session.date_start)}
</span>
{activeSessionMeta && (
<span className="rh-active-cov mono">
<span
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
aria-hidden="true"
/>
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
</span>
)}
<span className="rh-active-key mono">key {sessionKey}</span>
</div>
)}
<DatasetStrip datasets={data.datasets} />
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewView data={data} />}
{activeTab === 'race_story' && (
<div className="data-section">
<RaceStoryCanvas data={data} />
</div>
)}
{activeTab === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
stints={data.stints}
pit_stops={data.pit_stops}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'lap_data' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Lap Data</span>
{data.laps.length > 0 && (
<span className="sec-meta mono">{data.laps.length} samples</span>
)}
</div>
<LapsView laps={data.laps} drivers={data.drivers} />
</div>
)}
{activeTab === 'conditions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Conditions</span>
{data.weather.length > 0 && (
<span className="sec-meta mono">{data.weather.length} samples</span>
)}
</div>
<WeatherView weather={data.weather} />
</div>
)}
{activeTab === 'race_control' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Control</span>
{data.race_control.length > 0 && (
<span className="sec-meta mono">{data.race_control.length} messages</span>
)}
</div>
<RaceControlView messages={data.race_control} />
</div>
)}
{activeTab === 'data_status' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Data Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</div>
)}
</div>
)
}

81
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { Nav } from './components/Nav'
import { CommandCenterPage } from './pages/CommandCenterPage'
import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
type RaceHubSearch = {
session_key?: number
}
const rootRoute = createRootRoute({
component: () => (
<>
<Nav />
<Outlet />
</>
),
})
export const commandCenterRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: CommandCenterPage,
})
export const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
const sessionKey = Number(search.session_key)
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
},
component: function RaceHubRoute() {
const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
export const adminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
component: DataLibraryPage,
})
// Legacy alias — kept so any saved /data-library links keep working.
export const dataLibraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/data-library',
component: DataLibraryPage,
})
export const liveTimingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/live',
component: LiveTimingPage,
})
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
component: BriefingPage,
})
const routeTree = rootRoute.addChildren([
commandCenterRoute,
raceHubRoute,
adminRoute,
dataLibraryRoute,
liveTimingRoute,
briefingRoute,
])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

3202
frontend/src/styles/app.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CliCommands, ingestMeetingCommands, ingestSessionCommands } from '../components/CliCommands'
describe('CliCommands', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
})
})
it('renders commands with comments', () => {
render(
<CliCommands
commands={[{ comment: '# test', cmd: 'box-box --ingest-year 2025' }]}
/>,
)
expect(screen.getByText('# test')).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-year 2025')).toBeInTheDocument()
})
it('copies command on button click', async () => {
render(<CliCommands commands={[{ cmd: 'box-box --ingest-session 9472' }]} />)
fireEvent.click(screen.getByRole('button', { name: /Copy/i }))
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('box-box --ingest-session 9472')
expect(await screen.findByText('Copied')).toBeInTheDocument()
})
it('builds meeting ingest commands', () => {
const cmds = ingestMeetingCommands(1229)
expect(cmds[0].cmd).toBe('box-box --ingest-meeting 1229')
expect(cmds[1].cmd).toBe('box-box --ingest-meeting 1229 --dry-run')
})
it('builds session ingest commands', () => {
const cmds = ingestSessionCommands(9472)
expect(cmds[0].cmd).toBe('box-box --ingest-session 9472')
})
})

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { CommandCenterPage } from '../pages/CommandCenterPage'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchSeasonMeetings: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
}))
import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchWeekend, fetchLiveState } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{
session: {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
},
source: 'local',
datasets: fullDatasets,
},
],
}
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<CommandCenterPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: CommandCenterPage,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('CommandCenterPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
})
it('shows empty state when no seasons are ingested', async () => {
mockFetchSeasons.mockResolvedValue([])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
})
expect(screen.getByText('No local data yet')).toBeInTheDocument()
})
it('shows weekend identity band and schedule when data exists', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchSeasonMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('command-center')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
})
})

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DataLibraryPage } from '../pages/DataLibraryPage'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
}))
import { fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{
session: {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
},
source: 'local',
datasets: fullDatasets,
},
],
}
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<DataLibraryPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DataLibraryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
})
})
it('shows empty state when no seasons exist', async () => {
mockFetchSeasons.mockResolvedValue([])
renderPage()
expect(await screen.findByTestId('data-library-empty')).toBeInTheDocument()
expect(screen.getByText(/No ingested seasons yet/i)).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-year 2025')).toBeInTheDocument()
})
it('shows meetings and detail panel with CLI commands', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderPage()
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
await waitFor(() => {
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
})
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
expect(screen.getAllByText('Monaco').length).toBeGreaterThan(0)
expect(screen.getByText('11/11')).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-meeting 1229')).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-session 9472')).toBeInTheDocument()
})
it('shows partial badge for partial weekends', async () => {
const partialWeekend: Weekend = {
...weekend,
source: 'partial',
sessions: [{ ...weekend.sessions[0], source: 'partial', datasets: { meeting: { status: 'available', source: 'local' } } }],
}
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(partialWeekend)
renderPage()
expect(await screen.findByText('Partial')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DatasetStatusView } from '../components/DatasetStatusView'
import type { DatasetInfo } from '../types'
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 30 },
pit_stops: { status: 'available', source: 'local', count: 18 },
positions: { status: 'available', source: 'local', count: 120 },
race_control: { status: 'available', source: 'local', count: 5 },
weather: { status: 'available', source: 'local', count: 4 },
laps: { status: 'available', source: 'local', count: 200 },
}
const coreOnly: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'missing', source: 'none' },
pit_stops: { status: 'missing', source: 'none' },
positions: { status: 'missing', source: 'none' },
race_control: { status: 'missing', source: 'none' },
weather: { status: 'missing', source: 'none' },
laps: { status: 'missing', source: 'none' },
}
function renderView(datasets: Record<string, DatasetInfo>) {
const rootRoute = createRootRoute({
component: () => <DatasetStatusView datasets={datasets} />,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DatasetStatusView', () => {
it('shows 11/11 when all datasets are available', async () => {
renderView(fullDatasets)
await waitFor(() =>
expect(screen.getByText(/11\/11 datasets local/)).toBeInTheDocument(),
)
})
it('shows a Local badge for every available dataset', async () => {
renderView(fullDatasets)
await waitFor(() => expect(screen.getAllByText('Local')).toHaveLength(11))
})
it('shows Missing badges for missing datasets', async () => {
renderView(coreOnly)
await waitFor(() => expect(screen.getAllByText('Missing')).toHaveLength(6))
})
it('links to admin when datasets are missing instead of inlining CLI commands', async () => {
renderView(coreOnly)
await waitFor(() => {
expect(screen.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
'href',
'/admin',
)
})
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
})
it('does not surface ingest hints when fully covered', async () => {
renderView(fullDatasets)
await waitFor(() => expect(screen.getByText(/11\/11/)).toBeInTheDocument())
expect(screen.queryByText(/manage ingestion/i)).not.toBeInTheDocument()
})
it('renders record counts from the dataset payload', async () => {
renderView(fullDatasets)
await waitFor(() => {
const twenties = screen.getAllByText('20')
expect(twenties.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LapsView } from '../components/LapsView'
import type { Driver, Lap } from '../types'
const laps: Lap[] = [
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 1,
date_start: '2025-05-25T13:04:00Z',
lap_duration: 75.2,
is_pit_out_lap: false,
},
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 1,
date_start: '2025-05-25T13:04:01Z',
lap_duration: 72.1,
is_pit_out_lap: false,
},
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 2,
date_start: '2025-05-25T13:05:14Z',
lap_duration: 73.5,
is_pit_out_lap: true,
},
]
const drivers: Driver[] = [
{
driver_number: 44,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
first_name: 'Lewis',
last_name: 'Hamilton',
team_name: 'Ferrari',
team_colour: 'E80020',
headshot_url: '',
broadcast_name: 'L HAMILTON',
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
first_name: 'Max',
last_name: 'Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
headshot_url: '',
broadcast_name: 'M VERSTAPPEN',
session_key: 9472,
meeting_key: 1229,
},
]
describe('LapsView', () => {
it('renders compact best-lap rows by driver', () => {
render(<LapsView laps={laps} drivers={drivers} />)
expect(screen.getByTestId('laps-view')).toBeInTheDocument()
expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
expect(screen.getByText('Lewis Hamilton')).toBeInTheDocument()
expect(screen.getByText('1:12.100')).toBeInTheDocument()
expect(screen.getByText('+3.100')).toBeInTheDocument()
expect(screen.queryByText('FASTEST')).not.toBeInTheDocument()
expect(screen.getByText('Max Verstappen').closest('tr')).toHaveClass('lap-fastest-row')
})
it('shows a missing-data state when no laps are present', () => {
render(<LapsView laps={[]} />)
expect(screen.getByText(/Laps not ingested/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { LocalDataNavigator, countRaceHubDatasets, formatCoverageHint } from '../components/LocalDataNavigator'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
}))
import { fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{
session: {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
},
source: 'local',
datasets: fullDatasets,
},
],
}
function renderWithProviders(onSelectSession?: (sessionKey: number) => void) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<LocalDataNavigator onSelectSession={onSelectSession} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('navigation helpers', () => {
it('counts available Race Hub datasets', () => {
expect(countRaceHubDatasets(fullDatasets)).toEqual({ available: 11, total: 11 })
expect(countRaceHubDatasets({ meeting: { status: 'available', source: 'local' } })).toEqual({
available: 1,
total: 11,
})
})
it('formats coverage hint', () => {
expect(formatCoverageHint(fullDatasets)).toBe('11/11')
})
})
describe('LocalDataNavigator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows empty state when no seasons exist', async () => {
mockFetchSeasons.mockResolvedValue([])
renderWithProviders()
expect(await screen.findByTestId('local-nav-empty')).toBeInTheDocument()
expect(screen.getByText(/No ingested seasons yet/i)).toBeInTheDocument()
})
it('loads meetings for the first season by default', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
renderWithProviders()
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
await waitFor(() => {
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
})
expect(screen.getByText('Monaco')).toBeInTheDocument()
})
it('loads weekend sessions and calls onSelectSession', async () => {
const onSelectSession = vi.fn()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderWithProviders(onSelectSession)
await screen.findByText('Monaco')
fireEvent.click(screen.getByRole('button', { name: 'Sessions' }))
expect(await screen.findByTestId('weekend-sessions')).toBeInTheDocument()
expect(screen.getByText('11/11')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('open-session-9472'))
expect(onSelectSession).toHaveBeenCalledWith(9472)
})
})

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { RaceControlView } from '../components/RaceControlView'
import type { RaceControlMessage } from '../types'
const messages: RaceControlMessage[] = [
{
session_key: 9472,
meeting_key: 1229,
date: '2025-05-25T13:10:00Z',
category: 'Flag',
flag: 'YELLOW',
message: 'Yellow flag in sector 2',
scope: 'Sector',
driver_number: null,
lap_number: 6,
sector: 2,
qualifying_phase: null,
},
{
session_key: 9472,
meeting_key: 1229,
date: '2025-05-25T13:12:00Z',
category: 'Other',
flag: '',
message: 'Car 44 noted for track limits',
scope: 'Driver',
driver_number: 44,
lap_number: 8,
sector: null,
qualifying_phase: null,
},
]
describe('RaceControlView', () => {
it('renders race-control messages from the payload array', () => {
render(<RaceControlView messages={messages} />)
expect(screen.getByTestId('race-control-view')).toBeInTheDocument()
expect(screen.getByText('YELLOW')).toBeInTheDocument()
expect(screen.getByText('Yellow flag in sector 2')).toBeInTheDocument()
expect(screen.getByText('Car 44 noted for track limits')).toBeInTheDocument()
})
it('shows a missing-data state when no messages are present', () => {
render(<RaceControlView messages={[]} />)
expect(screen.getByText(/Race control messages not ingested/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,258 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
} from '@tanstack/react-router'
import { RaceHubPage } from '../pages/RaceHubPage'
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
vi.mock('../api', () => ({
fetchRaceHub: vi.fn(),
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
}))
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco Grand Prix',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const raceSession: Session = {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const qualSession: Session = {
session_key: 9471,
session_name: 'Qualifying',
session_type: 'Qualifying',
meeting_key: 1229,
date_start: '2025-05-24T14:00:00+00:00',
date_end: '2025-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 30 },
pit_stops: { status: 'available', source: 'local', count: 18 },
positions: { status: 'available', source: 'local', count: 120 },
race_control: { status: 'available', source: 'local', count: 5 },
weather: { status: 'available', source: 'local', count: 4 },
laps: { status: 'available', source: 'local', count: 200 },
}
const raceHub: RaceHub = {
source: 'local',
session_key: 9472,
datasets: fullDatasets,
meeting,
session: raceSession,
drivers: [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
first_name: 'Max',
last_name: 'Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
headshot_url: '',
broadcast_name: 'M VERSTAPPEN',
session_key: 9472,
meeting_key: 1229,
},
],
results: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: 5500,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
],
starting_grid: [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
session_key: 9472,
meeting_key: 1229,
lap_duration: 70.5,
},
],
stints: [],
pit_stops: [],
positions: [],
race_control: [],
weather: [
{
session_key: 9472,
meeting_key: 1229,
date: '2025-05-25T13:30:00+00:00',
air_temperature: 22,
track_temperature: 40,
humidity: 50,
pressure: 1010,
rainfall: 0,
wind_direction: 180,
wind_speed: 1.2,
},
],
laps: [
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
lap_number: 42,
date_start: '2025-05-25T14:00:00+00:00',
lap_duration: 71.5,
is_pit_out_lap: false,
},
],
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{ session: qualSession, source: 'local', datasets: fullDatasets },
{ session: raceSession, source: 'local', datasets: fullDatasets },
],
}
function renderRaceHub(sessionKey: number) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
),
})
const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (search: Record<string, unknown>) => {
const sk = Number(search.session_key)
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
},
component: function RaceHubRouteComponent() {
const { session_key } = raceHubRoute.useSearch()
return <RaceHubPage sessionKey={session_key ?? 0} />
},
})
const router = createRouter({
routeTree: rootRoute.addChildren([raceHubRoute]),
history: undefined,
})
// Navigate to the URL before mounting
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
return render(<RouterProvider router={router} />)
}
describe('RaceHubPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchRaceHub.mockResolvedValue(raceHub)
})
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
expect(screen.getByTestId('rh-identity')).toHaveTextContent('Monaco Grand Prix')
expect(screen.getByTestId('rh-identity')).toHaveTextContent('MON')
await waitFor(() =>
expect(screen.getByTestId('rh-session-9472')).toBeInTheDocument(),
)
expect(screen.getByTestId('rh-session-9471')).toBeInTheDocument()
// Overview is default
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
expect(screen.getByText('Winner')).toBeInTheDocument()
})
it('exposes Race Story sub-controls for classification, grid, and positions', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
expect(screen.getByText('VER')).toBeInTheDocument()
})
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
})
it('toggles the inline weekend switcher', async () => {
renderRaceHub(9472)
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StrategyView } from '../components/StrategyView'
import type { EnrichedResult, Stint, PitStop } from '../types'
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 44,
position: 2,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 5.1,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1229,
},
]
const stints: Stint[] = [
{
session_key: 9472,
driver_number: 1,
meeting_key: 1229,
stint_number: 1,
compound: 'MEDIUM',
lap_start: 1,
lap_end: 30,
tyre_age_at_start: 0,
},
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
stint_number: 1,
compound: 'SOFT',
lap_start: 1,
lap_end: 18,
tyre_age_at_start: 0,
},
]
const pitStops: PitStop[] = [
{
session_key: 9472,
driver_number: 44,
meeting_key: 1229,
lap_number: 19,
date: '2025-05-25T14:00:00+00:00',
pit_duration: 2.4,
lane_duration: 0,
stop_duration: 2.4,
},
]
describe('StrategyView — stints available', () => {
it('renders the strategy chart container', () => {
const { container } = render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument()
})
it('renders driver acronyms as SVG text', () => {
render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
})
it('renders an SVG stint chart', () => {
const { container } = render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelectorAll('rect').length).toBeGreaterThan(0)
})
it('does not show the stints-unavailable notice', () => {
render(
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
)
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
})
})
describe('StrategyView — stints missing', () => {
it('shows the missing-data notice', () => {
render(
<StrategyView results={results} stints={[]} pit_stops={[]} hasStints={false} />
)
expect(screen.getByText(/Stints not available/i)).toBeInTheDocument()
})
it('falls back to laps-completed table', () => {
render(
<StrategyView results={results} stints={[]} pit_stops={[]} hasStints={false} />
)
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getAllByText('78').length).toBe(2)
})
it('does not render the strategy chart', () => {
const { container } = render(
<StrategyView results={results} stints={[]} pit_stops={[]} hasStints={false} />
)
expect(container.querySelector('[data-testid="strategy-chart"]')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TabBar } from '../components/TabBar'
describe('TabBar', () => {
it('renders all Race Hub workspace tabs', () => {
render(<TabBar active="overview" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
})
it('marks the active tab with aria-selected', () => {
render(<TabBar active="strategy" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Strategy' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'false')
})
it('applies active class only to the active tab', () => {
render(<TabBar active="conditions" onChange={() => {}} />)
const active = screen.getByRole('tab', { name: 'Conditions' })
const inactive = screen.getByRole('tab', { name: 'Overview' })
expect(active.className).toContain('active')
expect(inactive.className).not.toContain('active')
})
it('calls onChange with the correct tab id when clicked', () => {
const onChange = vi.fn()
render(<TabBar active="overview" onChange={onChange} />)
fireEvent.click(screen.getByRole('tab', { name: 'Race Control' }))
expect(onChange).toHaveBeenCalledWith('race_control')
})
})

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { WeatherView } from '../components/WeatherView'
import type { WeatherSample } from '../types'
const weather: WeatherSample[] = [
{
session_key: 9472,
meeting_key: 1229,
date: '2025-05-25T13:00:00Z',
air_temperature: 20,
track_temperature: 30,
humidity: 60,
pressure: 1010,
rainfall: 0,
wind_direction: 180,
wind_speed: 2,
},
{
session_key: 9472,
meeting_key: 1229,
date: '2025-05-25T13:05:00Z',
air_temperature: 21,
track_temperature: 33,
humidity: 62,
pressure: 1011,
rainfall: 0.2,
wind_direction: 190,
wind_speed: 3,
},
]
describe('WeatherView', () => {
it('renders weather summary and recent samples', () => {
render(<WeatherView weather={weather} />)
expect(screen.getByTestId('weather-view')).toBeInTheDocument()
expect(screen.getByText('Avg air / track')).toBeInTheDocument()
expect(screen.getByText('20.5C / 31.5C')).toBeInTheDocument()
expect(screen.getByText('Rain samples')).toBeInTheDocument()
expect(screen.getByText('0.2')).toBeInTheDocument()
})
it('shows a missing-data state when no weather samples are present', () => {
render(<WeatherView weather={[]} />)
expect(screen.getByText(/Weather samples not ingested/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import {
countRaceHubDatasets,
formatCoverageHint,
countWeekendStats,
sessionTypeAbbrev,
isSessionComplete,
} from '../lib/coverage'
import type { DatasetInfo, Weekend } from '../types'
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
describe('coverage helpers', () => {
it('counts available Race Hub datasets', () => {
expect(countRaceHubDatasets(fullDatasets)).toEqual({ available: 11, total: 11 })
expect(countRaceHubDatasets({ meeting: { status: 'available', source: 'local' } })).toEqual({
available: 1,
total: 11,
})
})
it('formats coverage hint', () => {
expect(formatCoverageHint(fullDatasets)).toBe('11/11')
})
it('detects complete sessions', () => {
expect(isSessionComplete(fullDatasets)).toBe(true)
expect(isSessionComplete({ meeting: { status: 'available', source: 'local' } })).toBe(false)
})
it('abbreviates session types', () => {
expect(sessionTypeAbbrev('Race', 'Race')).toBe('R')
expect(sessionTypeAbbrev('Qualifying', 'Qualifying')).toBe('Q')
expect(sessionTypeAbbrev('Practice', 'Practice 1')).toBe('FP1')
})
it('counts weekend stats', () => {
const local: Weekend = {
source: 'local',
meeting_key: 1,
meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'local', datasets: fullDatasets }],
}
const partial: Weekend = {
source: 'partial',
meeting_key: 2,
meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'partial', datasets: {} }],
}
expect(countWeekendStats([local, partial, undefined])).toEqual({
full: 1,
partial: 1,
missing: 1,
total: 3,
})
})
})

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
extrapolateClock,
latestRaceControl,
positionDeltaClass,
parseLiveStateEvent,
rcFlagClass,
sortLiveTimingRows,
trackStatusLabel,
tyreClass,
tyreLabel,
} from '../lib/live'
import type { LiveStreamData } from '../types'
const snapshot: LiveStreamData = {
Drivers: {
'16': {
RacingNumber: '16',
Position: 1,
PrevPosition: 2,
GapToLeader: '',
Interval: '',
LastLapTime: '1:14.100',
LastLapPB: true,
LastLapOB: false,
BestLapTime: '1:13.900',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 20,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 21,
SpeedTrap: '',
Sectors: [],
},
'1': {
RacingNumber: '1',
Position: 2,
PrevPosition: 1,
GapToLeader: '+1.200',
Interval: '+1.200',
LastLapTime: '1:14.300',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '1:13.800',
BestLapPB: false,
BestLapOB: true,
BestLapNum: 19,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 21,
SpeedTrap: '',
Sectors: [],
},
},
DriverInfo: {
'16': {
RacingNumber: '16',
BroadcastName: 'C LECLERC',
Tla: 'LEC',
TeamName: 'Ferrari',
TeamColour: 'e8002d',
FirstName: 'Charles',
LastName: 'Leclerc',
},
'44': {
RacingNumber: '44',
BroadcastName: 'L HAMILTON',
Tla: 'HAM',
TeamName: 'Ferrari',
TeamColour: 'e8002d',
FirstName: 'Lewis',
LastName: 'Hamilton',
},
},
Tyres: {
'16': { Compound: 'MEDIUM', New: false, Age: 8 },
},
RCMessages: [
{ Time: '14:01', Category: 'Flag', Flag: 'GREEN', Message: 'GREEN LIGHT', Lap: 0 },
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
],
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
TrackStatus: '1',
CurrentLap: 21,
TotalLaps: 78,
Clock: '01:20:00',
ClockRefTime: '2026-05-25T12:00:00Z',
ClockExtrapolating: true,
Stints: {},
}
describe('live transforms', () => {
it('parses live EventSource snapshots without changing PascalCase data', () => {
const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot }))
expect(parsed?.is_live).toBe(true)
expect(parsed?.data?.Drivers['16'].RacingNumber).toBe('16')
})
it('sorts timing rows by live position and includes drivers with metadata only', () => {
const rows = sortLiveTimingRows(snapshot)
expect(rows.map((row) => row.RacingNumber)).toEqual(['16', '1', '44'])
expect(rows[2].Position).toBe(3)
})
it('formats tyre labels and classes', () => {
expect(tyreLabel({ Compound: 'MEDIUM', New: false, Age: 8 })).toBe('M +8')
expect(tyreClass({ Compound: 'INTERMEDIATE', New: true, Age: 1 })).toBe('tyre-inter')
expect(tyreLabel(undefined)).toBe('?')
})
it('maps track status and race control ordering', () => {
expect(trackStatusLabel('4')).toBe('SC')
expect(latestRaceControl(snapshot.RCMessages, 1)[0].Message).toBe('DRS ENABLED')
})
it('maps position delta and race-control flag classes', () => {
expect(positionDeltaClass(snapshot.Drivers['16'])).toBe('pos-gain')
expect(positionDeltaClass(snapshot.Drivers['1'])).toBe('pos-loss')
expect(positionDeltaClass({ ...snapshot.Drivers['1'], PrevPosition: 2, Position: 2 })).toBe('')
expect(rcFlagClass('GREEN')).toBe('rc-flag-green')
expect(rcFlagClass('safety car')).toBe('rc-flag-sc')
expect(rcFlagClass('virtual safety car')).toBe('rc-flag-vsc')
expect(rcFlagClass('unknown')).toBe('')
})
it('extrapolates the session clock from the reference time', () => {
expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30')
})
})

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest'
import {
currentMeeting,
currentAndNextSession,
focusMeetingKind,
focusMeetingLabel,
formatCountdown,
nextUpcomingMeeting,
pickFocusMeeting,
} from '../lib/schedule'
import type { Meeting, Session } from '../types'
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
meeting_key: 1,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T23:59:59+00:00',
year: 2025,
...overrides,
})
const session = (overrides: Partial<Session> = {}): Session => ({
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
...overrides,
})
describe('schedule helpers', () => {
it('picks current meeting when now is inside the weekend window', () => {
const now = new Date('2025-05-24T12:00:00Z')
const meetings = [meeting()]
expect(currentMeeting(meetings, now)?.meeting_key).toBe(1)
expect(pickFocusMeeting(meetings, now)?.meeting_key).toBe(1)
})
it('picks next upcoming meeting when all meetings are in the future', () => {
const now = new Date('2025-01-01T00:00:00Z')
const meetings = [meeting()]
expect(nextUpcomingMeeting(meetings, now)?.meeting_key).toBe(1)
expect(pickFocusMeeting(meetings, now)?.meeting_key).toBe(1)
expect(focusMeetingKind(meetings[0], now)).toBe('next')
expect(focusMeetingLabel('next')).toBe('Next Weekend')
})
it('falls back to most recent past meeting for historical local data', () => {
const now = new Date('2026-01-01T00:00:00Z')
const meetings = [meeting()]
expect(pickFocusMeeting(meetings, now)?.meeting_key).toBe(1)
expect(focusMeetingKind(meetings[0], now)).toBe('recent')
})
it('detects current and next sessions', () => {
const sessions = [
session({ session_key: 1, session_name: 'FP1', date_start: '2025-05-23T10:00:00+00:00', date_end: '2025-05-23T11:00:00+00:00' }),
session({ session_key: 2, session_name: 'Race', date_start: '2025-05-25T13:00:00+00:00', date_end: '2025-05-25T15:00:00+00:00' }),
]
const duringRace = new Date('2025-05-25T14:00:00+00:00')
expect(currentAndNextSession(sessions, duringRace).current?.session_key).toBe(2)
const beforeRace = new Date('2025-05-24T12:00:00+00:00')
expect(currentAndNextSession(sessions, beforeRace).next?.session_key).toBe(2)
})
it('formats countdown strings', () => {
const now = new Date('2025-05-25T12:00:00+00:00')
const target = new Date('2025-05-25T13:00:00+00:00')
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
})
})

View File

@@ -0,0 +1,6 @@
import '@testing-library/jest-dom'
Object.defineProperty(window, 'scrollTo', {
value: () => {},
writable: true,
})

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import {
teamColor,
formatDuration,
formatGap,
formatLapTime,
gridDelta,
gridDeltaClass,
positionClass,
} from '../utils'
describe('teamColor', () => {
it('prepends # to bare hex', () => {
expect(teamColor('e8002d')).toBe('#e8002d')
})
it('passes through already-prefixed hex', () => {
expect(teamColor('#3671c6')).toBe('#3671c6')
})
it('returns fallback for empty string', () => {
expect(teamColor('')).toBe('#444444')
})
it('returns fallback for undefined', () => {
expect(teamColor(undefined)).toBe('#444444')
})
})
describe('formatDuration', () => {
it('formats a race duration with hours', () => {
expect(formatDuration(5534.456)).toBe('1:32:14.456')
})
it('formats a sub-hour duration', () => {
expect(formatLapTime(74.892)).toBe('1:14.892')
})
it('returns — for null', () => {
expect(formatDuration(null)).toBe('—')
})
it('returns — for undefined', () => {
expect(formatDuration(undefined)).toBe('—')
})
it('handles array (qualifying)', () => {
expect(formatDuration([74.892])).toBe('1:14.892')
})
it('returns — for zero', () => {
expect(formatDuration(0)).toBe('—')
})
})
describe('formatGap', () => {
it('formats a numeric gap', () => {
expect(formatGap(3.456)).toBe('+3.456')
})
it('passes through a string gap (lapped)', () => {
expect(formatGap('+1 LAP')).toBe('+1 LAP')
})
it('returns — for null', () => {
expect(formatGap(null)).toBe('—')
})
it('handles array gap', () => {
expect(formatGap([8.123])).toBe('+8.123')
})
})
describe('formatLapTime', () => {
it('formats qualifying lap time', () => {
expect(formatLapTime(74.892)).toBe('1:14.892')
})
it('returns — for null', () => {
expect(formatLapTime(null)).toBe('—')
})
it('pads seconds correctly', () => {
expect(formatLapTime(64.5)).toBe('1:04.500')
})
})
describe('gridDelta', () => {
it('shows gain when finish position improved', () => {
expect(gridDelta(1, 3)).toBe('↑2')
})
it('shows loss when finish position dropped', () => {
expect(gridDelta(5, 2)).toBe('↓3')
})
it('shows — for same position', () => {
expect(gridDelta(4, 4)).toBe('—')
})
it('shows — when grid position is 0', () => {
expect(gridDelta(1, 0)).toBe('—')
})
})
describe('gridDeltaClass', () => {
it('returns pos-gain for improvement', () => {
expect(gridDeltaClass(1, 5)).toBe('pos-gain')
})
it('returns pos-loss for drop', () => {
expect(gridDeltaClass(6, 2)).toBe('pos-loss')
})
it('returns pos-same for no change', () => {
expect(gridDeltaClass(3, 3)).toBe('pos-same')
})
})
describe('positionClass', () => {
it('returns pos-p1 for first', () => {
expect(positionClass(1)).toBe('pos-p1')
})
it('returns pos-p2 for second', () => {
expect(positionClass(2)).toBe('pos-p2')
})
it('returns pos-p3 for third', () => {
expect(positionClass(3)).toBe('pos-p3')
})
it('returns pos-n for other positions', () => {
expect(positionClass(10)).toBe('pos-n')
})
})

290
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,290 @@
export interface DatasetInfo {
status: 'available' | 'missing' | 'skipped'
source: 'local' | 'openf1' | 'none' | 'na'
count?: number
}
export interface Meeting {
meeting_key: number
meeting_name: string
meeting_official_name: string
location: string
country_name: string
country_code: string
country_flag: string
circuit_short_name: string
date_start: string
date_end: string
year: number
}
export interface Session {
session_key: number
session_name: string
session_type: string
meeting_key: number
date_start: string
date_end: string
gmt_offset: string
}
export interface Driver {
driver_number: number
name_acronym: string
full_name: string
first_name: string
last_name: string
team_name: string
team_colour: string
headshot_url: string
broadcast_name: string
session_key: number
meeting_key: number
}
export interface EnrichedResult {
driver_number: number
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
dnf: boolean
dns: boolean
dsq: boolean
duration: number | number[] | null
gap_to_leader: number | string | number[] | null
number_of_laps: number
points: number
session_key: number
meeting_key: number
}
export interface EnrichedGrid {
driver_number: number
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
session_key: number
meeting_key: number
lap_duration: number | null
}
export interface RaceHub {
source: 'local' | 'partial' | 'none'
session_key: number
datasets: Record<string, DatasetInfo>
meeting?: Meeting
session?: Session
drivers: Driver[]
results: EnrichedResult[]
starting_grid: EnrichedGrid[]
stints: Stint[]
pit_stops: PitStop[]
positions: PositionSample[]
race_control: RaceControlMessage[]
weather: WeatherSample[]
laps: Lap[]
}
export interface Stint {
session_key: number
driver_number: number
meeting_key: number
stint_number: number
compound: string
lap_start: number
lap_end: number
tyre_age_at_start: number
}
export interface PitStop {
session_key: number
driver_number: number
meeting_key: number
lap_number: number
date: string
pit_duration: number
lane_duration: number
stop_duration: number
}
export interface PositionSample {
session_key: number
driver_number: number
meeting_key: number
date: string
position: number
}
export interface RaceControlMessage {
session_key: number
meeting_key: number
date: string
category: string
flag: string
message: string
scope: string
driver_number: number | null
lap_number: number | null
sector: number | null
qualifying_phase: number | null
}
export interface WeatherSample {
session_key: number
meeting_key: number
date: string
air_temperature: number
track_temperature: number
humidity: number
pressure: number
rainfall: number
wind_direction: number
wind_speed: number
}
export interface Lap {
session_key: number
driver_number: number
meeting_key: number
lap_number: number
date_start: string
lap_duration: number | null
is_pit_out_lap: boolean
}
export interface WeekendSession {
session: Session
source: 'local' | 'partial' | 'none'
datasets: Record<string, DatasetInfo>
}
export interface Weekend {
source: 'local' | 'partial' | 'none'
meeting_key: number
meeting: Meeting
sessions: WeekendSession[]
default_session_key?: number
}
export interface LiveStateResponse {
is_live: boolean
data: LiveStreamData | null
}
export interface LiveSectorData {
Value: string
PersonalFastest: boolean
OverallFastest: boolean
}
export interface LiveDriverData {
RacingNumber: string
Position: number
PrevPosition: number
GapToLeader: string
Interval: string
LastLapTime: string
LastLapPB: boolean
LastLapOB: boolean
BestLapTime: string
BestLapPB: boolean
BestLapOB: boolean
BestLapNum: number
InPit: boolean
PitOut: boolean
Retired: boolean
KnockedOut: boolean
Cutoff: boolean
OnFlyingLap: boolean
NumberOfLaps: number
SpeedTrap: string
Sectors: LiveSectorData[]
}
export interface LiveDriverInfo {
RacingNumber: string
BroadcastName: string
Tla: string
TeamName: string
TeamColour: string
FirstName: string
LastName: string
}
export interface LiveTyreData {
Compound: string
New: boolean
Age: number
}
export interface LiveRCMessage {
Time: string
Category: string
Flag: string
Message: string
Lap: number
}
export interface LiveWeatherData {
AirTemp: number
TrackTemp: number
Humidity: number
WindSpeed: number
WindDir: number
Rainfall: boolean
}
export interface LiveSessionMeta {
MeetingName: string
CircuitName: string
SessionType: string
SessionName: string
}
export interface LiveStintData {
Compound: string
New: boolean
Laps: number
}
export interface LiveStreamData {
Drivers: Record<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo>
Tyres: Record<string, LiveTyreData>
RCMessages: LiveRCMessage[]
Weather: LiveWeatherData
Session: LiveSessionMeta
TrackStatus: string
CurrentLap: number
TotalLaps: number
Clock: string
ClockRefTime: string
ClockExtrapolating: boolean
Stints: Record<string, LiveStintData[]>
}
export interface NewsItem {
source: string
title: string
url: string
published_at?: string
summary?: string
category?: string
fetched_at: string
og_image_url?: string
og_description?: string
read_at?: string
}
export interface ArticleContent {
title: string
byline?: string
excerpt?: string
image_url?: string
content: string
site_name?: string
}

96
frontend/src/utils.ts Normal file
View File

@@ -0,0 +1,96 @@
export function teamColor(hex: string | undefined): string {
if (!hex) return '#444444'
return hex.startsWith('#') ? hex : `#${hex}`
}
export function formatDuration(val: number | number[] | null | undefined): string {
if (val == null) return '—'
const s = Array.isArray(val) ? val[0] : val
if (typeof s !== 'number' || isNaN(s) || s <= 0) return '—'
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = (s % 60).toFixed(3)
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${sec.padStart(6, '0')}`
return `${m}:${sec.padStart(6, '0')}`
}
export function formatGap(val: number | string | number[] | null | undefined): string {
if (val == null) return '—'
if (typeof val === 'string') return val
const g = Array.isArray(val) ? val[0] : val
if (typeof g !== 'number' || isNaN(g)) return '—'
return `+${g.toFixed(3)}`
}
export function formatLapTime(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return '—'
const m = Math.floor(seconds / 60)
const s = (seconds % 60).toFixed(3)
return `${m}:${s.padStart(6, '0')}`
}
export function formatDate(dateStr: string | undefined): string {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
} catch {
return dateStr
}
}
export function gridDelta(finishPos: number, gridPos: number): string {
if (!gridPos || !finishPos) return '—'
const delta = gridPos - finishPos
if (delta > 0) return `${delta}`
if (delta < 0) return `${Math.abs(delta)}`
return '—'
}
export function gridDeltaClass(finishPos: number, gridPos: number): string {
if (!gridPos || !finishPos) return 'pos-same'
const delta = gridPos - finishPos
if (delta > 0) return 'pos-gain'
if (delta < 0) return 'pos-loss'
return 'pos-same'
}
export function positionClass(pos: number): string {
if (pos === 1) return 'pos-p1'
if (pos === 2) return 'pos-p2'
if (pos === 3) return 'pos-p3'
return 'pos-n'
}
/** Classified finish order; position 0 (DNF/DNS) sorts last. */
export function finishPositionOrder(pos: number): number {
return pos > 0 ? pos : 9999
}
export function compareFinishPosition(a: number, b: number): number {
return finishPositionOrder(a) - finishPositionOrder(b)
}
export function stripHtml(html: string): string {
if (!html) return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent ?? ''
}
export function timeAgo(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const seconds = Math.floor((Date.now() - d.getTime()) / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"types": ["vitest/globals"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: process.env.PORT ? parseInt(process.env.PORT) : 5173,
proxy: {
'/api': {
target: `http://localhost:${process.env.BOXBOX_API_PORT ?? '8080'}`,
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})

10
go.mod
View File

@@ -3,15 +3,19 @@ module github.com/AmanTahiliani/box-box
go 1.25.6
require (
codeberg.org/readeck/go-readability/v2 v2.1.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
github.com/sahilm/fuzzy v0.1.1
golang.org/x/net v0.55.0
modernc.org/sqlite v1.47.0
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
@@ -23,6 +27,8 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -35,8 +41,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

115
go.sum
View File

@@ -1,3 +1,9 @@
codeberg.org/readeck/go-readability/v2 v2.1.1 h1:1tEwxFuUqDRP5JABzDHXGWRx5p9S7TElS3U8qQwXC5Y=
codeberg.org/readeck/go-readability/v2 v2.1.1/go.mod h1:x3WG9GpWWnkRb7ajP1NmOKSHbafxNUb736lrDZXeXrs=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -22,10 +28,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -42,6 +56,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -52,28 +67,108 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -96,11 +97,30 @@ func cacheDBPath() string {
// ttlForURL determines the appropriate TTL based on the URL pattern.
// Returns 0 (CacheTTLForever) for historical data that will never change.
func ttlForURL(url string) time.Duration {
var year int
if idx := strings.Index(url, "year="); idx != -1 && len(url) >= idx+9 {
yearStr := url[idx+5 : idx+9]
if y, err := strconv.Atoi(yearStr); err == nil {
year = y
}
}
currentYear := time.Now().Year()
// Historical data — completed past seasons never change.
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
if year > 0 && year < currentYear {
return CacheTTLForever
}
// For current year or unspecified year (e.g. meeting/session list endpoint that includes a session key query):
if year == currentYear || year == 0 {
// Cache current year meetings and sessions metadata for 24h
if (strings.Contains(url, "/meetings") || strings.Contains(url, "/sessions")) &&
!strings.Contains(url, "/session_result") {
return CacheTTLLong
}
}
// Live telemetry endpoints — change every few seconds during a session.
if strings.Contains(url, "/position") ||
strings.Contains(url, "/intervals") ||

View File

@@ -38,6 +38,11 @@ func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *O
}
}
// BaseURL returns the configured OpenF1 API root URL.
func (c *OpenF1Client) BaseURL() string {
return c.url
}
// Cache returns the underlying Cache so callers can access track outline
// storage and other persistent data directly.
func (c *OpenF1Client) Cache() *Cache {

View File

@@ -20,6 +20,15 @@ import (
// from ~30 min before a session starts until ~30 min after it ends.
var ErrLiveSessionLocked = errors.New("live F1 session in progress — API access is restricted to authenticated users until the session ends")
// RateLimitError is returned when the OpenF1 API rate limit is reached (HTTP 429).
type RateLimitError struct {
RetryAfter time.Duration
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("openf1 API rate limit hit: retry after %v", e.RetryAfter)
}
// IsLiveSessionError reports whether err (or any error in its chain) is the
// live-session lockout error from the OpenF1 API.
func IsLiveSessionError(err error) bool {
@@ -89,6 +98,63 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
}
// FetchStrict performs a GET using the HTTP cache for fresh entries only.
// Unlike get(), it never falls back to expired cache on failure — intended
// for ingestion workflows that need authoritative responses or explicit errors.
func (c *OpenF1Client) FetchStrict(url string) ([]byte, error) {
if cachedData, ok := c.cache.Get(url); ok {
return cachedData, nil
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
retryAfterDur := 500 * time.Millisecond
if retryAfterHeader := resp.Header.Get("Retry-After"); retryAfterHeader != "" {
if seconds, err := strconv.Atoi(retryAfterHeader); err == nil {
retryAfterDur = time.Duration(seconds) * time.Second
}
}
return nil, &RateLimitError{RetryAfter: retryAfterDur}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusUnauthorized {
var apiErr struct {
Detail string `json:"detail"`
}
if json.Unmarshal(data, &apiErr) == nil && apiErr.Detail != "" {
detail := strings.ToLower(apiErr.Detail)
if strings.Contains(detail, "live") && strings.Contains(detail, "session") {
return nil, fmt.Errorf("%w", ErrLiveSessionLocked)
}
return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail)
}
}
return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url)
}
_ = c.cache.Set(url, data)
return data, nil
}
// tryStale attempts to return stale cached data when a live request has failed.
// If stale data exists it sets the client's stale flag and returns the data.
// Otherwise it returns the original error unchanged so callers can handle it.
@@ -207,24 +273,12 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
return 0, errors.New("no Race sessions found")
}
// Walk backwards to find the most recent completed race.
now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- {
s := sessions[i]
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
}
latestKey, ok := latestCompletedRaceSessionKey(sessions, now)
if !ok {
return 0, errors.New("no completed Race sessions found")
}
return 0, errors.New("no completed Race sessions found")
return latestKey, nil
}
// getLatestRaceSessionKeyForYear returns the session_key of the most recent
@@ -245,25 +299,47 @@ func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
return 0, fmt.Errorf("no Race sessions found for year %d", year)
}
// Walk backwards to find the most recent completed race.
now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- {
s := sessions[i]
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
// Fallback: if no DateEnd, check DateStart + 3 hours as a rough estimate.
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
latestKey, ok := latestCompletedRaceSessionKey(sessions, now)
if !ok {
return 0, fmt.Errorf("no completed Race sessions found for year %d", year)
}
return latestKey, nil
}
func latestCompletedRaceSessionKey(sessions []models.Session, now time.Time) (int, bool) {
var latestKey int
var latestTime time.Time
for _, s := range sessions {
completedAt, ok := completedRaceTime(s)
if !ok || !completedAt.Before(now) {
continue
}
if latestKey == 0 || completedAt.After(latestTime) {
latestKey = s.SessionKey
latestTime = completedAt
}
}
return 0, fmt.Errorf("no completed Race sessions found for year %d", year)
return latestKey, latestKey != 0
}
func completedRaceTime(s models.Session) (time.Time, bool) {
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil {
return endTime, true
}
}
if s.DateStart == "" {
return time.Time{}, false
}
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err != nil {
return time.Time{}, false
}
return startTime.Add(3 * time.Hour), true
}
// GetLatestDriverChampionship returns championship standings for the most recent
@@ -311,6 +387,7 @@ func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult,
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
models.SortSessionResults(result)
return result, nil
}

View File

@@ -0,0 +1,60 @@
package api
import (
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
func TestLatestCompletedRaceSessionKeyUsesDatesNotInputOrder(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
sessions := []models.Session{
{
SessionKey: 1,
SessionName: "Race",
DateStart: "2025-12-01T13:00:00+00:00",
DateEnd: "2025-12-01T15:00:00+00:00",
},
{
SessionKey: 2,
SessionName: "Race",
DateStart: "2025-03-01T13:00:00+00:00",
DateEnd: "2025-03-01T15:00:00+00:00",
},
}
sessionKey, ok := latestCompletedRaceSessionKey(sessions, now)
if !ok {
t.Fatal("expected a completed race")
}
if sessionKey != 1 {
t.Fatalf("sessionKey = %d, want 1", sessionKey)
}
}
func TestLatestCompletedRaceSessionKeyIgnoresFutureSessions(t *testing.T) {
now := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
sessions := []models.Session{
{
SessionKey: 1,
SessionName: "Race",
DateStart: "2025-12-01T13:00:00+00:00",
DateEnd: "2025-12-01T15:00:00+00:00",
},
{
SessionKey: 2,
SessionName: "Race",
DateStart: "2025-05-01T13:00:00+00:00",
DateEnd: "2025-05-01T15:00:00+00:00",
},
}
sessionKey, ok := latestCompletedRaceSessionKey(sessions, now)
if !ok {
t.Fatal("expected a completed race")
}
if sessionKey != 2 {
t.Fatalf("sessionKey = %d, want 2", sessionKey)
}
}

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

@@ -0,0 +1,982 @@
package ingest
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
// Options configures ingestion behavior.
type Options struct {
DryRun bool
Force bool // Re-fetch even if session_coverage says 'complete'
RequestDelay time.Duration
MaxRetries int
RetryBackoff time.Duration
Progress *Progress
}
// DefaultOptions returns conservative ingestion defaults.
func DefaultOptions() Options {
return Options{
RequestDelay: 300 * time.Millisecond,
MaxRetries: 5,
RetryBackoff: 500 * time.Millisecond,
Progress: NewProgress(nil),
}
}
// SessionSummary captures the outcome of ingesting one session within a meeting run.
type SessionSummary struct {
SessionKey int `json:"session_key"`
SessionName string `json:"session_name,omitempty"`
Summary Summary `json:"summary"`
}
// Summary captures the outcome of an ingestion run.
type Summary struct {
ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"`
Status string `json:"status"`
DryRun bool `json:"dry_run"`
Meetings int `json:"meetings"`
Sessions int `json:"sessions"`
Drivers int `json:"drivers"`
SessionResults int `json:"session_results"`
StartingGrid int `json:"starting_grid"`
Stints int `json:"stints"`
PitStops int `json:"pit_stops"`
Positions int `json:"positions"`
RaceControl int `json:"race_control"`
Weather int `json:"weather"`
Laps int `json:"laps"`
RawPayloads int `json:"raw_payloads"`
RawInserted int `json:"raw_inserted"`
SessionSummaries []SessionSummary `json:"session_summaries,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// Service orchestrates OpenF1-to-store ingestion workflows.
type Service struct {
store *store.Store
source Source
opts Options
}
// NewService creates an ingestion service.
func NewService(st *store.Store, source Source, opts Options) *Service {
if opts.MaxRetries <= 0 {
opts.MaxRetries = 5
}
if opts.RetryBackoff <= 0 {
opts.RetryBackoff = 500 * time.Millisecond
}
if opts.RequestDelay <= 0 {
opts.RequestDelay = 300 * time.Millisecond
}
if opts.Progress == nil {
opts.Progress = NewProgress(nil)
}
return &Service{store: st, source: source, opts: opts}
}
// IngestYear fetches and stores all meetings for a season year.
func (s *Service) IngestYear(year int) (Summary, error) {
summary := Summary{
ScopeType: "year",
ScopeKey: fmt.Sprintf("%d", year),
DryRun: s.opts.DryRun,
}
if year < 2023 {
return summary, fmt.Errorf("invalid year %d: must be 2023 or later", year)
}
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
if err != nil {
return summary, err
}
s.opts.Progress.Step("fetching meetings for %d", year)
fetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
return s.source.FetchMeetingsForYear(year)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(fetch, nil, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, m := range meetings {
if s.opts.DryRun {
summary.Meetings++
continue
}
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Meetings++
}
sessionFailures := 0
partialSessions := 0
var totalSessionsCount int
for _, m := range meetings {
s.opts.Progress.Step("fetching sessions for meeting %d (%s)", m.MeetingKey, m.MeetingName)
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
return s.source.FetchSessionsForMeeting(int(m.MeetingKey))
})
if err != nil {
summary.Errors = append(summary.Errors, fmt.Sprintf("meeting %d (%s) sessions: %v", m.MeetingKey, m.MeetingName, err))
continue
}
summary.RawPayloads++
if !s.opts.DryRun {
mkVal := int(m.MeetingKey)
inserted, err := s.storeRaw(sessionFetch, &mkVal, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, sess := range sessions {
if s.opts.DryRun {
summary.Sessions++
totalSessionsCount++
continue
}
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Sessions++
totalSessionsCount++
}
for _, sess := range sessions {
s.opts.Progress.Step("ingesting Race Hub datasets for session %d (%s)", sess.SessionKey, sess.SessionName)
sessSummary, err := s.ingestSessionDatasets(sess)
ss := SessionSummary{
SessionKey: sess.SessionKey,
SessionName: sess.SessionName,
Summary: sessSummary,
}
if err != nil {
sessionFailures++
ss.Summary.Status = "failed"
ss.Summary.Errors = append(ss.Summary.Errors, err.Error())
summary.Errors = append(summary.Errors, fmt.Sprintf(
"session %d (%s): %v", sess.SessionKey, sess.SessionName, err,
))
}
if err == nil && sessSummary.Status == "partial" {
partialSessions++
for _, partialErr := range sessSummary.Errors {
summary.Errors = append(summary.Errors, fmt.Sprintf(
"session %d (%s): %s", sess.SessionKey, sess.SessionName, partialErr,
))
}
}
summary.SessionSummaries = append(summary.SessionSummaries, ss)
summary.mergeCounts(sessSummary)
}
}
summary.Status = meetingStatus(sessionFailures, partialSessions, totalSessionsCount, s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil
}
// IngestMeeting fetches meeting metadata, all sessions, and Race Hub datasets for each session.
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
summary := Summary{
ScopeType: "meeting",
ScopeKey: fmt.Sprintf("%d", meetingKey),
DryRun: s.opts.DryRun,
}
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
if err != nil {
return summary, err
}
s.opts.Progress.Step("fetching meeting %d", meetingKey)
meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
return s.source.FetchMeetingsForMeetingKey(meetingKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
mk := meetingKey
inserted, err := s.storeRaw(meetingFetch, &mk, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, m := range meetings {
if s.opts.DryRun {
summary.Meetings++
continue
}
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Meetings++
}
s.opts.Progress.Step("fetching sessions for meeting %d", meetingKey)
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
return s.source.FetchSessionsForMeeting(meetingKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
mk := meetingKey
inserted, err := s.storeRaw(sessionFetch, &mk, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, sess := range sessions {
if s.opts.DryRun {
summary.Sessions++
continue
}
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Sessions++
}
sessionFailures := 0
partialSessions := 0
for _, sess := range sessions {
s.opts.Progress.Step("ingesting Race Hub datasets for session %d (%s)", sess.SessionKey, sess.SessionName)
sessSummary, err := s.ingestSessionDatasets(sess)
ss := SessionSummary{
SessionKey: sess.SessionKey,
SessionName: sess.SessionName,
Summary: sessSummary,
}
if err != nil {
sessionFailures++
ss.Summary.Status = "failed"
ss.Summary.Errors = append(ss.Summary.Errors, err.Error())
summary.Errors = append(summary.Errors, fmt.Sprintf(
"session %d (%s): %v", sess.SessionKey, sess.SessionName, err,
))
}
if err == nil && sessSummary.Status == "partial" {
partialSessions++
for _, partialErr := range sessSummary.Errors {
summary.Errors = append(summary.Errors, fmt.Sprintf(
"session %d (%s): %s", sess.SessionKey, sess.SessionName, partialErr,
))
}
}
summary.SessionSummaries = append(summary.SessionSummaries, ss)
summary.mergeCounts(sessSummary)
}
summary.Status = meetingStatus(sessionFailures, partialSessions, len(sessions), s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
if sessionFailures > 0 {
return summary, fmt.Errorf(
"meeting %d: %d of %d session(s) failed",
meetingKey, sessionFailures, len(sessions),
)
}
return summary, nil
}
// IngestSession ingests Race Hub v1 datasets for a single session.
func (s *Service) IngestSession(sessionKey int) (Summary, error) {
summary := Summary{
ScopeType: "session",
ScopeKey: fmt.Sprintf("%d", sessionKey),
DryRun: s.opts.DryRun,
}
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
if err != nil {
return summary, err
}
s.opts.Progress.Step("fetching session %d", sessionKey)
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
return s.source.FetchSessionsForSessionKey(sessionKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
if len(sessions) == 0 {
err := fmt.Errorf("session %d not found", sessionKey)
return s.finishFailed(runID, summary, err)
}
sess := sessions[0]
meetingKey := sess.MeetingKey
sk := sessionKey
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(sessionFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
s.opts.Progress.Step("fetching meeting %d for session context", meetingKey)
meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
return s.source.FetchMeetingsForMeetingKey(meetingKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(meetingFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
for _, m := range meetings {
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Meetings++
}
} else {
summary.Meetings = len(meetings)
}
s.delay()
if s.opts.DryRun {
summary.Sessions++
} else {
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Sessions++
}
datasetSummary, err := s.ingestSessionDatasets(sess)
summary.mergeCounts(datasetSummary)
summary.Errors = append(summary.Errors, datasetSummary.Errors...)
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Status = datasetSummary.Status
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil
}
func (s *Service) ingestSessionDatasets(sess models.Session) (Summary, error) {
sessionKey := sess.SessionKey
meetingKey := sess.MeetingKey
summary := Summary{
ScopeType: "session",
ScopeKey: fmt.Sprintf("%d", sessionKey),
DryRun: s.opts.DryRun,
}
sk := sessionKey
coverage, err := s.store.GetSessionCoverage(sessionKey)
if err != nil {
coverage = make(map[string]store.CoverageEntry)
}
// 1. Ingest drivers
if cov, ok := coverage["drivers"]; ok && cov.Status == "complete" && !s.opts.Force {
s.opts.Progress.Step("drivers already complete for session %d, skipping", sessionKey)
summary.Drivers = cov.RowCount
} else {
s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
return s.source.FetchDriversForSession(sessionKey)
})
if err != nil {
if !s.opts.DryRun {
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
}
return summary, err
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
if err != nil {
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
return summary, err
}
if inserted {
summary.RawInserted++
}
for _, d := range drivers {
if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
return summary, err
}
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
return summary, err
}
summary.Drivers++
}
_ = s.store.UpsertCoverage(sessionKey, "drivers", "complete", len(drivers), "")
} else {
summary.Drivers = len(drivers)
}
s.delay()
}
// 2. Ingest session_result
if cov, ok := coverage["session_result"]; ok && cov.Status == "complete" && !s.opts.Force {
s.opts.Progress.Step("session_result already complete for session %d, skipping", sessionKey)
summary.SessionResults = cov.RowCount
} else {
s.opts.Progress.Step("fetching session results for session %d", sessionKey)
resultFetch, results, err := fetchWithRetry(s, func() (FetchResult, []models.SessionResult, error) {
return s.source.FetchSessionResult(sessionKey)
})
if err != nil {
if !s.opts.DryRun {
_ = s.store.UpsertCoverage(sessionKey, "session_result", "failed", 0, err.Error())
}
return summary, err
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
if err != nil {
_ = s.store.UpsertCoverage(sessionKey, "session_result", "failed", 0, err.Error())
return summary, err
}
if inserted {
summary.RawInserted++
}
for _, r := range results {
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
_ = s.store.UpsertCoverage(sessionKey, "session_result", "failed", 0, err.Error())
return summary, err
}
summary.SessionResults++
}
_ = s.store.UpsertCoverage(sessionKey, "session_result", "complete", len(results), "")
} else {
summary.SessionResults = len(results)
}
s.delay()
}
// 3. Optional datasets
optionalIngests := []struct {
name string
run func(*Summary, int, int) error
}{
{name: "starting_grid", run: func(summary *Summary, meetingKey, sessionKey int) error {
return s.ingestStartingGrid(summary, sess)
}},
{name: "stints", run: s.ingestStints},
{name: "pit_stops", run: s.ingestPitStops},
{name: "positions", run: s.ingestPositions},
{name: "race_control", run: s.ingestRaceControl},
{name: "weather", run: s.ingestWeather},
{name: "laps", run: s.ingestLaps},
}
for _, optional := range optionalIngests {
if cov, ok := coverage[optional.name]; ok && cov.Status == "complete" && !s.opts.Force {
s.opts.Progress.Step("%s already complete for session %d, skipping", optional.name, sessionKey)
switch optional.name {
case "starting_grid":
summary.StartingGrid = cov.RowCount
case "stints":
summary.Stints = cov.RowCount
case "pit_stops":
summary.PitStops = cov.RowCount
case "positions":
summary.Positions = cov.RowCount
case "race_control":
summary.RaceControl = cov.RowCount
case "weather":
summary.Weather = cov.RowCount
case "laps":
summary.Laps = cov.RowCount
}
continue
}
var prevCount int
switch optional.name {
case "starting_grid":
prevCount = summary.StartingGrid
case "stints":
prevCount = summary.Stints
case "pit_stops":
prevCount = summary.PitStops
case "positions":
prevCount = summary.Positions
case "race_control":
prevCount = summary.RaceControl
case "weather":
prevCount = summary.Weather
case "laps":
prevCount = summary.Laps
}
err := optional.run(&summary, meetingKey, sk)
if err != nil {
summary.Errors = append(summary.Errors, fmt.Sprintf("%s: %v", optional.name, err))
if !s.opts.DryRun {
_ = s.store.UpsertCoverage(sessionKey, optional.name, "failed", 0, err.Error())
}
} else {
if !s.opts.DryRun {
var newCount int
switch optional.name {
case "starting_grid":
newCount = summary.StartingGrid - prevCount
case "stints":
newCount = summary.Stints - prevCount
case "pit_stops":
newCount = summary.PitStops - prevCount
case "positions":
newCount = summary.Positions - prevCount
case "race_control":
newCount = summary.RaceControl - prevCount
case "weather":
newCount = summary.Weather - prevCount
case "laps":
newCount = summary.Laps - prevCount
}
_ = s.store.UpsertCoverage(sessionKey, optional.name, "complete", newCount, "")
}
}
}
summary.Status = statusForErrors(s.opts.DryRun, summary.Errors)
return summary, nil
}
func (s *Summary) mergeCounts(other Summary) {
s.Drivers += other.Drivers
s.SessionResults += other.SessionResults
s.StartingGrid += other.StartingGrid
s.Stints += other.Stints
s.PitStops += other.PitStops
s.Positions += other.Positions
s.RaceControl += other.RaceControl
s.Weather += other.Weather
s.Laps += other.Laps
s.RawPayloads += other.RawPayloads
s.RawInserted += other.RawInserted
}
func meetingStatus(sessionFailures, partialSessions, sessionTotal int, dryRun bool) string {
if dryRun {
return "dry_run"
}
if sessionFailures == 0 {
if partialSessions > 0 {
return "partial"
}
return "completed"
}
if sessionFailures == sessionTotal {
return "failed"
}
return "partial"
}
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
if s.opts.DryRun {
return 0, nil
}
return s.store.CreateIngestionRun(scopeType, scopeKey, false)
}
func (s *Service) finishRun(runID int64, summary Summary) {
if s.opts.DryRun || runID == 0 {
return
}
b, _ := json.Marshal(summary)
_ = s.store.FinishIngestionRun(runID, summary.Status, string(b))
}
func (s *Service) finishFailed(runID int64, summary Summary, err error) (Summary, error) {
summary.Status = "failed"
summary.Errors = append(summary.Errors, err.Error())
if runID != 0 && !s.opts.DryRun {
b, _ := json.Marshal(summary)
_ = s.store.FinishIngestionRun(runID, summary.Status, string(b))
}
s.opts.Progress.Summary(summary)
return summary, err
}
func (s *Service) storeRaw(fetch FetchResult, meetingKey, sessionKey *int) (bool, error) {
_, inserted, err := s.store.InsertRawPayload(store.RawPayload{
Source: sourceOpenF1,
Endpoint: fetch.Endpoint,
RequestKey: fetch.RequestKey,
MeetingKey: meetingKey,
SessionKey: sessionKey,
Payload: string(fetch.Body),
FetchedAt: fetch.FetchedAt,
ProvenanceJSON: provenanceJSON(fetch),
})
return inserted, err
}
func (s *Service) delay() {
if s.opts.RequestDelay > 0 {
time.Sleep(s.opts.RequestDelay)
}
}
func (s *Service) ingestStartingGrid(summary *Summary, sess models.Session) error {
sessionKey := sess.SessionKey
meetingKey := sess.MeetingKey
sourceSessionKey, err := s.startingGridSourceSessionKey(sess)
if err != nil {
return err
}
if sourceSessionKey == sessionKey {
s.opts.Progress.Step("fetching starting grid for session %d", sessionKey)
} else {
s.opts.Progress.Step("fetching starting grid for session %d from qualifying session %d", sessionKey, sourceSessionKey)
}
fetch, grid, err := fetchWithRetry(s, func() (FetchResult, []models.StartingGrid, error) {
return s.source.FetchStartingGrid(sourceSessionKey)
})
if err != nil {
return err
}
if sourceSessionKey != sessionKey {
fetch.RequestKey = fmt.Sprintf("%s;target_session_key=%d", fetch.RequestKey, sessionKey)
}
mk := meetingKey
sk := sessionKey
summary.RawPayloads++
if s.opts.DryRun {
summary.StartingGrid = len(grid)
s.delay()
return nil
}
inserted, err := s.storeRaw(fetch, &mk, &sk)
if err != nil {
return err
}
if inserted {
summary.RawInserted++
}
for _, g := range grid {
g.SessionKey = sessionKey
g.MeetingKey = meetingKey
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
return err
}
summary.StartingGrid++
}
s.delay()
return nil
}
func (s *Service) startingGridSourceSessionKey(sess models.Session) (int, error) {
if !isRaceSession(sess) {
return sess.SessionKey, nil
}
_, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
return s.source.FetchSessionsForMeeting(sess.MeetingKey)
})
if err != nil {
return 0, err
}
s.delay()
for _, candidate := range sessions {
if isQualifyingSession(candidate) {
return candidate.SessionKey, nil
}
}
return 0, fmt.Errorf("no qualifying session found for meeting %d", sess.MeetingKey)
}
func isRaceSession(sess models.Session) bool {
return strings.EqualFold(sess.SessionType, "Race") || strings.EqualFold(sess.SessionName, "Race")
}
func isQualifyingSession(sess models.Session) bool {
return strings.EqualFold(sess.SessionType, "Qualifying") || strings.EqualFold(sess.SessionName, "Qualifying")
}
func (s *Service) ingestStints(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching stints for session %d", sessionKey)
fetch, stints, err := fetchWithRetry(s, func() (FetchResult, []models.Stint, error) {
return s.source.FetchStintsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, st := range stints {
if err := s.store.UpsertStint(stintToStore(st)); err != nil {
return err
}
summary.Stints++
}
return nil
}, func() { summary.Stints = len(stints) })
}
func (s *Service) ingestPitStops(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching pit stops for session %d", sessionKey)
fetch, pits, err := fetchWithRetry(s, func() (FetchResult, []models.Pit, error) {
return s.source.FetchPitStopsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, p := range pits {
if err := s.store.UpsertPitStop(pitStopToStore(p)); err != nil {
return err
}
summary.PitStops++
}
return nil
}, func() { summary.PitStops = len(pits) })
}
func (s *Service) ingestPositions(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching positions for session %d", sessionKey)
fetch, positions, err := fetchWithRetry(s, func() (FetchResult, []models.Position, error) {
return s.source.FetchPositionsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, p := range positions {
if err := s.store.UpsertPositionSample(positionToStore(p)); err != nil {
return err
}
summary.Positions++
}
return nil
}, func() { summary.Positions = len(positions) })
}
func (s *Service) ingestRaceControl(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching race control for session %d", sessionKey)
fetch, messages, err := fetchWithRetry(s, func() (FetchResult, []models.RaceControl, error) {
return s.source.FetchRaceControlForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, rc := range messages {
if err := s.store.UpsertRaceControlMessage(raceControlToStore(rc)); err != nil {
return err
}
summary.RaceControl++
}
return nil
}, func() { summary.RaceControl = len(messages) })
}
func (s *Service) ingestWeather(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching weather for session %d", sessionKey)
fetch, samples, err := fetchWithRetry(s, func() (FetchResult, []models.Weather, error) {
return s.source.FetchWeatherForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, w := range samples {
if err := s.store.UpsertWeatherSample(weatherToStore(w)); err != nil {
return err
}
summary.Weather++
}
return nil
}, func() { summary.Weather = len(samples) })
}
func (s *Service) ingestLaps(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching laps for session %d", sessionKey)
fetch, laps, err := fetchWithRetry(s, func() (FetchResult, []models.Lap, error) {
return s.source.FetchLapsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, l := range laps {
if err := s.store.UpsertLap(lapToStore(l)); err != nil {
return err
}
summary.Laps++
}
return nil
}, func() { summary.Laps = len(laps) })
}
func (s *Service) storeAnalyticsFetch(
summary *Summary,
fetch FetchResult,
meetingKey, sessionKey int,
storeRows func() error,
setDryRunCount func(),
) error {
mk := meetingKey
sk := sessionKey
summary.RawPayloads++
if s.opts.DryRun {
setDryRunCount()
s.delay()
return nil
}
inserted, err := s.storeRaw(fetch, &mk, &sk)
if err != nil {
return err
}
if inserted {
summary.RawInserted++
}
if err := storeRows(); err != nil {
return err
}
s.delay()
return nil
}
func statusForDryRun(dryRun bool) string {
if dryRun {
return "dry_run"
}
return "completed"
}
func statusForErrors(dryRun bool, errs []string) string {
if dryRun {
return "dry_run"
}
if len(errs) > 0 {
return "partial"
}
return "completed"
}
type fetchFunc[T any] func() (FetchResult, T, error)
func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) {
var zero T
var lastErr error
for attempt := 0; attempt < s.opts.MaxRetries; attempt++ {
if attempt > 0 {
backoff := s.opts.RetryBackoff * time.Duration(1<<attempt)
jitter := time.Duration(rand.Int63n(int64(backoff / 4)))
wait := backoff + jitter
if lastErr != nil {
var rle *api.RateLimitError
if errors.As(lastErr, &rle) && rle.RetryAfter > wait {
wait = rle.RetryAfter
}
}
time.Sleep(wait)
}
fetch, data, err := fn()
if err == nil {
return fetch, data, nil
}
lastErr = err
if api.IsLiveSessionError(err) {
return FetchResult{}, zero, err
}
if !isRetryable(err) {
return FetchResult{}, zero, err
}
}
return FetchResult{}, zero, lastErr
}
func isRetryable(err error) bool {
if err == nil {
return false
}
var rle *api.RateLimitError
if errors.As(err, &rle) {
return true
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "status 429") ||
strings.Contains(msg, "status 5") ||
strings.Contains(msg, "timeout") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "temporary") ||
strings.Contains(msg, "rate limit") {
return true
}
var netErr interface{ Timeout() bool }
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return false
}

Some files were not shown because too many files have changed in this diff Show More