mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Compare commits
35 Commits
161e871c53
...
024accfb3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
024accfb3d | ||
|
|
a2aa772838 | ||
|
|
3eb74a9083 | ||
|
|
e3453de788 | ||
|
|
e060bcba24 | ||
|
|
937674f808 | ||
|
|
bc1c551a82 | ||
|
|
c25822dee0 | ||
|
|
c172bb59b1 | ||
|
|
7ce2b1ace4 | ||
|
|
3bd169c55c | ||
|
|
ee88a07aa1 | ||
|
|
84a8827244 | ||
|
|
79b0b9f469 | ||
|
|
a0f135aac1 | ||
|
|
9532206522 | ||
|
|
e539abcc5f | ||
|
|
571edb9b5a | ||
|
|
10729a906c | ||
|
|
17c94d83ad | ||
|
|
404029ff24 | ||
|
|
e410a01db1 | ||
|
|
5eb0983ffb | ||
|
|
95060b07a5 | ||
|
|
0992fc03e8 | ||
|
|
f2339a00a9 | ||
|
|
2c9db0213c | ||
|
|
1661f8dec3 | ||
|
|
b5d87775a6 | ||
|
|
dfd0cf7c12 | ||
|
|
c7438f0ed8 | ||
|
|
5470b0df38 | ||
|
|
32d500f5af | ||
|
|
e08255db70 | ||
|
|
517c6b987b |
16
.gitignore
vendored
16
.gitignore
vendored
@@ -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
256
README.md
@@ -1,92 +1,228 @@
|
||||
# 🏎️ box-box
|
||||
# box-box
|
||||
|
||||

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

|
||||

|
||||
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 TUI’s 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 UI’s 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.*
|
||||
|
||||
264
cmd/main.go
264
cmd/main.go
@@ -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
|
||||
}
|
||||
|
||||
179
documentations/refactor/01-data-sources.md
Normal file
179
documentations/refactor/01-data-sources.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Data Sources
|
||||
|
||||
## Summary
|
||||
|
||||
`box-box` should treat data sources as inputs to a local product database, not as
|
||||
page-level dependencies. The current app fetches too much data on demand from
|
||||
OpenF1, which breaks down during free-tier lockouts and makes non-live screens
|
||||
feel empty. The live mode succeeds because it uses the official F1 live timing
|
||||
feed directly.
|
||||
|
||||
## Confirmed Sources
|
||||
|
||||
### OpenF1 REST API
|
||||
|
||||
Reference: https://openf1.org/docs/
|
||||
|
||||
Current usage:
|
||||
|
||||
- Meetings and sessions.
|
||||
- Drivers.
|
||||
- Championship standings.
|
||||
- Session results and starting grid.
|
||||
- Laps, stints, pit stops, positions, intervals.
|
||||
- Race control, weather, overtakes.
|
||||
- Car data, location, team radio metadata.
|
||||
|
||||
Strengths:
|
||||
|
||||
- Good historical/session data source.
|
||||
- JSON over simple HTTP.
|
||||
- Broad endpoint coverage.
|
||||
- Query filtering by fields and time ranges.
|
||||
|
||||
Limitations:
|
||||
|
||||
- Free-tier access can be locked during live sessions.
|
||||
- On-demand fetching is unreliable as a product behavior.
|
||||
- API schema or access rules can change.
|
||||
- High-volume endpoints can be expensive to fetch repeatedly.
|
||||
|
||||
Policy:
|
||||
|
||||
- Use OpenF1 primarily for ingestion and backfill.
|
||||
- Do not make historical Web pages depend on fresh OpenF1 calls.
|
||||
- Store successful fetches into the local domain database and raw payload log.
|
||||
|
||||
### Official F1 SignalR Live Feed
|
||||
|
||||
Endpoint: https://livetiming.formula1.com/signalr
|
||||
|
||||
Current code connects to the old ASP.NET SignalR protocol, negotiates a
|
||||
connection token, opens a websocket, and subscribes to the `Streaming` hub.
|
||||
|
||||
Current subscribed topics:
|
||||
|
||||
- `Heartbeat`
|
||||
- `TimingData`
|
||||
- `DriverList`
|
||||
- `LapCount`
|
||||
- `ExtrapolatedClock`
|
||||
- `TrackStatus`
|
||||
- `RaceControlMessages`
|
||||
- `WeatherData`
|
||||
- `SessionInfo`
|
||||
- `CurrentTyres`
|
||||
- `TimingAppData`
|
||||
- `TimingStats`
|
||||
|
||||
Strengths:
|
||||
|
||||
- Best current source for live timing.
|
||||
- Provides race-control updates quickly.
|
||||
- Avoids OpenF1 REST lockout during active sessions.
|
||||
- Powers the strongest part of the existing app.
|
||||
|
||||
Limitations:
|
||||
|
||||
- Payloads are less formally documented than OpenF1.
|
||||
- Topic schemas can drift.
|
||||
- Testing live behavior is difficult outside active sessions.
|
||||
- Current parser lives in `internal/ui`, which couples live source handling to
|
||||
the TUI layer.
|
||||
|
||||
Policy:
|
||||
|
||||
- Treat SignalR as the authoritative live source while a session is active.
|
||||
- Extract parsing and live-state logic into reusable backend/domain code.
|
||||
- Forward live state to the Web UI through SSE initially.
|
||||
- Research whether live snapshots/events should be persisted.
|
||||
|
||||
### Existing SQLite HTTP Cache
|
||||
|
||||
Current location: user cache directory under `box-box/cache.db`.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- Stores raw HTTP responses by URL.
|
||||
- Applies TTL rules based on URL patterns.
|
||||
- Can return stale responses when OpenF1 fails.
|
||||
- Stores track outlines in a structured table.
|
||||
|
||||
Strengths:
|
||||
|
||||
- Useful as a fallback.
|
||||
- Already integrated with the OpenF1 client.
|
||||
- Reduces repeated network calls.
|
||||
|
||||
Limitations:
|
||||
|
||||
- Not a queryable domain model.
|
||||
- URL keys are poor product identifiers.
|
||||
- Cannot easily power analytics, replay, ingestion status, or data provenance.
|
||||
- Pruning/TTL behavior is cache-oriented, not history-oriented.
|
||||
|
||||
Policy:
|
||||
|
||||
- Keep the raw cache as a fallback layer.
|
||||
- Do not use it as the primary application database.
|
||||
- Add a separate domain schema for product features.
|
||||
|
||||
## Candidate Source
|
||||
|
||||
### Official F1 Static Archived Timing Files
|
||||
|
||||
Reference:
|
||||
https://livef1.goktugocal.com/livetimingf1/data_topics.html
|
||||
|
||||
Examples in public references include:
|
||||
|
||||
- `SessionInfo.json`
|
||||
- `ArchiveStatus.json`
|
||||
- `TrackStatus.jsonStream`
|
||||
- `SessionData.json`
|
||||
- `TyreStintSeries.json`
|
||||
- `SessionStatus.json`
|
||||
- `TimingDataF1.json`
|
||||
|
||||
Potential strengths:
|
||||
|
||||
- Could provide replay-quality archived live timing.
|
||||
- May fill gaps between OpenF1 REST data and SignalR live data.
|
||||
- May support historical race reconstruction.
|
||||
|
||||
Known uncertainties:
|
||||
|
||||
- Session path mapping must be researched.
|
||||
- Stability and access guarantees are unclear.
|
||||
- Topic schemas and file availability may vary by year/session.
|
||||
- Legal and operational usage expectations need review.
|
||||
|
||||
Policy for now:
|
||||
|
||||
- Do not make core architecture depend on this source yet.
|
||||
- Assign a dedicated research track to validate feasibility.
|
||||
- If adopted, ingest it through the same raw-plus-normalized source pipeline.
|
||||
|
||||
## Source Authority Tiers
|
||||
|
||||
1. Local SQLite domain database.
|
||||
- Primary read source for Web UI historical and completed-session data.
|
||||
2. Official F1 SignalR live feed.
|
||||
- Primary source during active sessions.
|
||||
3. OpenF1 REST ingestion/backfill.
|
||||
- Primary source for populating local historical data.
|
||||
4. Optional F1 static archive source.
|
||||
- Research candidate for richer replay and archived live timing.
|
||||
5. Raw HTTP cache fallback.
|
||||
- Last-resort resilience layer, not a product data model.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should SignalR snapshots/events be persisted during live sessions?
|
||||
- If persisted, should live data become the authoritative record for that
|
||||
session or a supplemental event stream?
|
||||
- Which OpenF1 endpoints are essential for v1 local-first Race Hub?
|
||||
- Can static archived timing files be mapped reliably from OpenF1 sessions?
|
||||
- What data should be refreshed after a session ends, and when should it become
|
||||
immutable?
|
||||
|
||||
216
documentations/refactor/02-backend-architecture.md
Normal file
216
documentations/refactor/02-backend-architecture.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Backend Architecture
|
||||
|
||||
## Summary
|
||||
|
||||
The backend should move from direct page handlers calling OpenF1 into a layered
|
||||
local-first architecture. Source clients fetch data, ingestion persists it,
|
||||
store/query packages expose domain reads, and Web handlers return read models
|
||||
with source and freshness metadata.
|
||||
|
||||
## Proposed Package Boundaries
|
||||
|
||||
### `internal/store`
|
||||
|
||||
Owns SQLite as the local domain database.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Schema creation and migrations.
|
||||
- Typed upsert methods for domain records.
|
||||
- Typed read methods for screens and backend services.
|
||||
- Raw payload storage.
|
||||
- Ingestion metadata and provenance.
|
||||
- Transactions and batch writes.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- Calling OpenF1 directly.
|
||||
- Knowing Web UI route behavior.
|
||||
- Rendering derived frontend-specific structures unless they are shared read
|
||||
models.
|
||||
|
||||
### `internal/ingest`
|
||||
|
||||
Coordinates backfill, refresh, and opportunistic fetches.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Ingest year, meeting, or session.
|
||||
- Fetch required endpoints through source clients.
|
||||
- Persist raw payloads and normalized rows.
|
||||
- Track partial successes and failures.
|
||||
- Support resumable, idempotent runs.
|
||||
- Respect rate limits and free-tier constraints.
|
||||
|
||||
Default ingestion modes:
|
||||
|
||||
- CLI bulk ingestion for years, meetings, and sessions.
|
||||
- Opportunistic small fetches in Web mode when a user opens missing data.
|
||||
- Explicit refresh mode for completed data when needed.
|
||||
|
||||
Rate-limit defaults:
|
||||
|
||||
- Bulk ingestion must be resumable and idempotent.
|
||||
- Bulk ingestion should default to conservative sequential fetching with a
|
||||
delay between OpenF1 requests.
|
||||
- Failed requests should use bounded exponential backoff with jitter.
|
||||
- HTTP 429 and live-session lockout should pause or stop the current run rather
|
||||
than tight-loop retries.
|
||||
- `--dry-run` should show planned datasets and estimated request count before a
|
||||
large ingest.
|
||||
|
||||
### OpenF1 Source Client Layer
|
||||
|
||||
The current `internal/api` client can remain, but it should become one source
|
||||
adapter rather than the main application data layer.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Build OpenF1 URLs.
|
||||
- Apply auth headers when `OPENF1_API_KEY` exists.
|
||||
- Decode source payloads into source/domain structs.
|
||||
- Preserve stale fallback behavior where useful.
|
||||
|
||||
Future direction:
|
||||
|
||||
- Make source fetches observable by ingestion metadata.
|
||||
- Avoid direct UI route dependency on source calls.
|
||||
|
||||
### Live Timing Bridge
|
||||
|
||||
The current live parser should be extracted out of `internal/ui` into reusable
|
||||
backend/domain logic.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Connect to official F1 SignalR.
|
||||
- Parse topic payloads into typed live events/state.
|
||||
- Maintain current live snapshot.
|
||||
- Broadcast snapshots to Web clients through SSE.
|
||||
- Feed TUI live mode without coupling parser code to Bubble Tea.
|
||||
- Persist live events/snapshots as an append-only stream once the bridge is
|
||||
extracted.
|
||||
|
||||
Persistence policy:
|
||||
|
||||
- Live SignalR data should be stored separately from normalized post-session
|
||||
OpenF1 records.
|
||||
- Live data represents what was broadcast at the time, not necessarily the
|
||||
corrected final historical record.
|
||||
- A later reconciliation step can compare live stream data with OpenF1
|
||||
post-session records.
|
||||
|
||||
### Web API Read Models
|
||||
|
||||
Web handlers should become thin adapters from query services to JSON.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Validate route/query parameters.
|
||||
- Call local-first query/read services.
|
||||
- Return consistent response envelopes.
|
||||
- Include source/freshness metadata.
|
||||
|
||||
Suggested response metadata:
|
||||
|
||||
- `source`: `local`, `api`, `cache`, `live`, or `missing`.
|
||||
- `last_ingested_at`.
|
||||
- `is_stale`.
|
||||
- `missing_datasets`.
|
||||
- `errors` where partial data is returned.
|
||||
|
||||
### CLI Ingestion Commands
|
||||
|
||||
CLI commands should make bulk ingestion explicit and user-controlled.
|
||||
|
||||
Candidate commands/flags:
|
||||
|
||||
- `--ingest-year 2024`
|
||||
- `--ingest-meeting <meeting_key>`
|
||||
- `--ingest-session <session_key>`
|
||||
- `--refresh`
|
||||
- `--dry-run`
|
||||
|
||||
CLI output should include:
|
||||
|
||||
- What will be fetched.
|
||||
- What is already local.
|
||||
- What succeeded.
|
||||
- What failed.
|
||||
- Whether the run is resumable.
|
||||
|
||||
## Local-First Read Behavior
|
||||
|
||||
Default rule:
|
||||
|
||||
1. Read from local domain DB.
|
||||
2. If missing and request scope is small, optionally fetch from OpenF1.
|
||||
3. Persist successful fetches.
|
||||
4. Return local/read-model data with metadata.
|
||||
5. If OpenF1 is unavailable, return partial local data and clear missing/stale
|
||||
metadata rather than an empty page.
|
||||
|
||||
Examples:
|
||||
|
||||
- Opening a completed race with all local data should perform no OpenF1 calls.
|
||||
- Opening a completed race with missing weather may opportunistically fetch only
|
||||
weather.
|
||||
- Opening a whole season should not silently trigger a large backfill.
|
||||
- During live-session lockout, historical pages should still render from local
|
||||
data.
|
||||
|
||||
## Opportunistic Fetch Policy
|
||||
|
||||
Allowed by default:
|
||||
|
||||
- Single meeting sessions.
|
||||
- Single session results/grid/weather/race control.
|
||||
- Small metadata gaps needed to render a screen.
|
||||
|
||||
Not allowed by default:
|
||||
|
||||
- Full season backfills.
|
||||
- High-volume telemetry/location/car data.
|
||||
- Repeated refresh loops during API lockout.
|
||||
- Silent destructive refresh of completed local data.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
The existing SQLite HTTP cache should remain operational during the refactor.
|
||||
The new domain database should be introduced without requiring users to delete
|
||||
their current cache.
|
||||
|
||||
Default migration stance:
|
||||
|
||||
- Keep the current cache tables and stale fallback behavior intact.
|
||||
- Introduce domain tables through `internal/store`.
|
||||
- Prefer a separate domain database file at first if it materially reduces
|
||||
migration risk; using the same SQLite file remains acceptable if table names
|
||||
and migrations are carefully isolated.
|
||||
- Do not attempt to transform arbitrary URL-keyed cache entries into domain rows
|
||||
automatically.
|
||||
- New ingestion runs should populate domain tables from fresh source fetches or
|
||||
explicitly supported raw payloads.
|
||||
- Web routes can migrate endpoint by endpoint from source-first to local-first.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
The backend should explicitly represent:
|
||||
|
||||
- Local data available.
|
||||
- Local data partial.
|
||||
- Local data missing.
|
||||
- OpenF1 locked/unavailable.
|
||||
- Stale cache fallback used.
|
||||
- Live feed connected/disconnected.
|
||||
- Ingestion partial failure.
|
||||
|
||||
The Web UI should be able to show these states without guesswork.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should API response envelopes be introduced globally or per endpoint during
|
||||
migration?
|
||||
- How should source schema drift be detected and surfaced?
|
||||
- What is the minimum dataset required for a Race Hub to be considered
|
||||
complete?
|
||||
195
documentations/refactor/03-database-design.md
Normal file
195
documentations/refactor/03-database-design.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Database Design
|
||||
|
||||
## Summary
|
||||
|
||||
SQLite should become the local source of truth for historical and completed
|
||||
session data. The design should store both raw source payloads and normalized
|
||||
domain rows. Raw payloads preserve source fidelity and make reprocessing
|
||||
possible. Normalized rows power fast product queries, analytics, and stable Web
|
||||
screens.
|
||||
|
||||
## Storage Strategy
|
||||
|
||||
Use two layers:
|
||||
|
||||
1. Raw source storage.
|
||||
- Preserve fetched payloads exactly enough to reprocess later.
|
||||
- Track source, endpoint/topic, parameters, fetch time, status, and errors.
|
||||
2. Normalized domain tables.
|
||||
- Queryable application data keyed by F1 identifiers.
|
||||
- Built from successful source payloads.
|
||||
- Safe to upsert idempotently.
|
||||
|
||||
## Raw Payload Tables
|
||||
|
||||
Candidate tables:
|
||||
|
||||
- `source_payloads`
|
||||
- `id`
|
||||
- `source`
|
||||
- `resource`
|
||||
- `request_key`
|
||||
- `url_or_topic`
|
||||
- `params_json`
|
||||
- `payload_json`
|
||||
- `fetched_at`
|
||||
- `status`
|
||||
- `error`
|
||||
- `schema_version`
|
||||
|
||||
- `ingestion_runs`
|
||||
- `id`
|
||||
- `scope_type`
|
||||
- `scope_key`
|
||||
- `started_at`
|
||||
- `finished_at`
|
||||
- `status`
|
||||
- `refresh`
|
||||
- `summary_json`
|
||||
|
||||
- `ingestion_items`
|
||||
- `id`
|
||||
- `run_id`
|
||||
- `dataset`
|
||||
- `meeting_key`
|
||||
- `session_key`
|
||||
- `status`
|
||||
- `source`
|
||||
- `started_at`
|
||||
- `finished_at`
|
||||
- `error`
|
||||
|
||||
## Normalized Domain Tables
|
||||
|
||||
Core calendar/session tables:
|
||||
|
||||
- `meetings`
|
||||
- `sessions`
|
||||
- `circuits`
|
||||
|
||||
Participant tables:
|
||||
|
||||
- `drivers`
|
||||
- `session_drivers`
|
||||
- `teams` or team snapshots by season/session.
|
||||
|
||||
Classification and standings:
|
||||
|
||||
- `session_results`
|
||||
- `starting_grids`
|
||||
- `driver_championship_standings`
|
||||
- `constructor_championship_standings`
|
||||
|
||||
Race/session analysis:
|
||||
|
||||
- `laps`
|
||||
- `stints`
|
||||
- `pit_stops`
|
||||
- `positions`
|
||||
- `intervals`
|
||||
- `race_control_messages`
|
||||
- `weather_samples`
|
||||
- `overtakes`
|
||||
|
||||
Telemetry and spatial data:
|
||||
|
||||
- `car_data_samples`
|
||||
- `location_samples`
|
||||
- `track_outlines`
|
||||
|
||||
Media metadata:
|
||||
|
||||
- `team_radio_messages`
|
||||
|
||||
Derived/read-model candidates:
|
||||
|
||||
- `session_dataset_status`
|
||||
- `race_key_moments`
|
||||
- `driver_session_summaries`
|
||||
- `race_lap_snapshots`
|
||||
|
||||
Derived tables should be added only when query cost or UI complexity justifies
|
||||
them. Start with normalized source tables and build read models in Go unless
|
||||
performance argues otherwise.
|
||||
|
||||
## Provenance and Freshness
|
||||
|
||||
Each normalized dataset should be traceable to source ingestion metadata.
|
||||
|
||||
Track:
|
||||
|
||||
- Source: OpenF1, SignalR, static archive, manual, cache.
|
||||
- First ingested time.
|
||||
- Last ingested time.
|
||||
- Last successful refresh.
|
||||
- Last error.
|
||||
- Completion status.
|
||||
- Whether stale fallback was used.
|
||||
|
||||
This metadata supports the Data Library screen and makes partial data honest.
|
||||
|
||||
## Immutability Policy
|
||||
|
||||
Completed historical sessions:
|
||||
|
||||
- Treat as immutable after successful ingestion.
|
||||
- Do not refetch unless `--refresh` is explicitly requested.
|
||||
- Allow reprocessing from raw payloads if schema or read models change.
|
||||
|
||||
Current/future sessions:
|
||||
|
||||
- Treat as refreshable.
|
||||
- Allow opportunistic metadata fetches.
|
||||
- Avoid high-volume refreshes without explicit action.
|
||||
|
||||
Live sessions:
|
||||
|
||||
- SignalR is authoritative for live state.
|
||||
- Persist live data as an append-only event/snapshot stream after the live
|
||||
bridge is extracted.
|
||||
- Keep live data separate from normalized post-session OpenF1 records until
|
||||
reconciliation is designed.
|
||||
- Treat live data as the record of what was seen during the session, not as the
|
||||
corrected final historical truth.
|
||||
|
||||
## Migration And File Layout
|
||||
|
||||
The current project already creates a SQLite cache database for raw HTTP
|
||||
responses. The domain database should be introduced without breaking that cache.
|
||||
|
||||
Default stance:
|
||||
|
||||
- Existing cache tables are infrastructure, not product domain state.
|
||||
- New domain tables should be owned by `internal/store`.
|
||||
- A separate domain DB file is the lower-risk first implementation unless a
|
||||
schema design pass shows strong reasons to reuse the same file.
|
||||
- If the same file is reused, domain tables must be namespaced clearly and
|
||||
migrations must avoid touching the current `cache` table except through
|
||||
deliberate cache work.
|
||||
- Do not auto-migrate URL-keyed cache entries into domain rows.
|
||||
- Use explicit ingestion to populate the new domain tables.
|
||||
|
||||
## High-Volume Data
|
||||
|
||||
High-volume tables need careful indexing and retention decisions:
|
||||
|
||||
- `car_data_samples`
|
||||
- `location_samples`
|
||||
- `positions`
|
||||
- `intervals`
|
||||
- `laps` for full-season analysis
|
||||
|
||||
Initial policy:
|
||||
|
||||
- Ingest high-volume telemetry only when explicitly requested.
|
||||
- Keep Race Hub v1 focused on results, strategy, laps, race control, weather,
|
||||
positions, and track outlines.
|
||||
|
||||
## Research Questions
|
||||
|
||||
- Exact indexes for Race Hub, Live Replay, Driver Explorer, and Standings.
|
||||
- Whether `positions` and `intervals` should be downsampled or stored in full.
|
||||
- Whether `car_data_samples` and `location_samples` should be optional datasets.
|
||||
- How to map official F1 static archive sessions to OpenF1 `session_key`.
|
||||
- Whether to use SQLite FTS for race-control/team-radio search.
|
||||
- How to version schema migrations without adding unnecessary framework weight.
|
||||
231
documentations/refactor/04-web-ui-product.md
Normal file
231
documentations/refactor/04-web-ui-product.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Web UI Product
|
||||
|
||||
## Summary
|
||||
|
||||
The Web UI should become the primary way to use `box-box`. The product should
|
||||
feel like an F1 operations room: fast, dense when needed, precise, and native to
|
||||
race-weekend workflows. It should work well on phone and iPad, while still
|
||||
scaling into a richer desktop dashboard.
|
||||
|
||||
## Product Priorities
|
||||
|
||||
- Race-weekend first.
|
||||
- Live Timing and Race Hub receive the highest polish.
|
||||
- Historical pages should be local-first and reliable.
|
||||
- Data availability should be visible, not mysterious.
|
||||
- Density should be configurable.
|
||||
- TUI live mode remains supported but does not require Web feature parity.
|
||||
|
||||
## Core Screens
|
||||
|
||||
### Command Center
|
||||
|
||||
Default landing screen.
|
||||
|
||||
Shows:
|
||||
|
||||
- Current or upcoming race weekend.
|
||||
- Next session countdown.
|
||||
- Live session state.
|
||||
- Weekend schedule.
|
||||
- Weather snapshot.
|
||||
- Championship context.
|
||||
- Local data availability.
|
||||
- Shortcuts into Live Timing, Weekend, Race Hub, Standings, and Data Library.
|
||||
|
||||
### Season Calendar
|
||||
|
||||
Year-based browsing screen.
|
||||
|
||||
Shows:
|
||||
|
||||
- All meetings for the selected year.
|
||||
- Round, country, circuit, date range.
|
||||
- Upcoming/live/completed state.
|
||||
- Local ingestion status.
|
||||
- Key outcomes after completion: winner, pole, fastest lap where available.
|
||||
- Filters for missing data, completed races, sprint weekends, and upcoming
|
||||
rounds.
|
||||
|
||||
### Weekend Page
|
||||
|
||||
One workspace per Grand Prix weekend.
|
||||
|
||||
Shows:
|
||||
|
||||
- Meeting metadata.
|
||||
- Circuit and location.
|
||||
- Session cards.
|
||||
- Schedule and status.
|
||||
- Dataset completeness.
|
||||
- Entry points into each session view.
|
||||
|
||||
### Race / Session Hub
|
||||
|
||||
Main historical analysis workspace.
|
||||
|
||||
For races, prioritize the strategy story:
|
||||
|
||||
- Final classification.
|
||||
- Starting grid and grid delta.
|
||||
- Stint chart with compounds and pit stops.
|
||||
- Safety car and VSC overlays.
|
||||
- Position evolution.
|
||||
- Lap-time comparison.
|
||||
- Race-control timeline.
|
||||
- Weather timeline.
|
||||
- Driver race execution summaries.
|
||||
- Replay scrubber with lap-by-lap standings and events.
|
||||
|
||||
For practice and qualifying:
|
||||
|
||||
- Classification.
|
||||
- Best laps and sector breakdown.
|
||||
- Lap progression.
|
||||
- Driver comparison.
|
||||
- Session events and weather context.
|
||||
|
||||
### Live Timing
|
||||
|
||||
Primary active-session screen.
|
||||
|
||||
Shows:
|
||||
|
||||
- Timing tower.
|
||||
- Session clock, lap count, and track status.
|
||||
- Position, gap, interval, tyre, tyre age, pit state.
|
||||
- Last lap, best lap, sector state, DRS/track status where available.
|
||||
- Race-control messages.
|
||||
- Battles.
|
||||
- Pit window predictions.
|
||||
- Pinned drivers.
|
||||
- Visual in-app alerts.
|
||||
|
||||
### Live Track View
|
||||
|
||||
Initially a mode inside Live Timing.
|
||||
|
||||
Shows:
|
||||
|
||||
- Circuit outline.
|
||||
- Live car positions.
|
||||
- Team/driver coloring.
|
||||
- Selected/pinned driver focus.
|
||||
- Mini timing list.
|
||||
- Track/flag context where available.
|
||||
|
||||
### Drivers
|
||||
|
||||
Driver explorer.
|
||||
|
||||
Shows:
|
||||
|
||||
- Current season driver list.
|
||||
- Driver profile data.
|
||||
- Team, number, and headshot where available.
|
||||
- Season points and trend.
|
||||
- Race-by-race result table.
|
||||
- Teammate comparison.
|
||||
- Tyre/stint tendencies.
|
||||
- Live pinned-driver mode during active sessions.
|
||||
|
||||
### Standings
|
||||
|
||||
Championship context screen.
|
||||
|
||||
Shows:
|
||||
|
||||
- Driver standings.
|
||||
- Constructor standings.
|
||||
- Points gaps.
|
||||
- Movement since previous race.
|
||||
- Race-by-race points accumulation.
|
||||
- What changed after a selected Grand Prix.
|
||||
|
||||
### Data Library
|
||||
|
||||
Local data transparency screen.
|
||||
|
||||
Shows:
|
||||
|
||||
- Seasons available locally.
|
||||
- Weekend and session dataset completeness.
|
||||
- Missing datasets.
|
||||
- Last ingested timestamps.
|
||||
- Source/staleness state.
|
||||
- Suggested ingestion commands.
|
||||
- API lockout and stale cache explanations.
|
||||
|
||||
### Settings
|
||||
|
||||
Local app preferences.
|
||||
|
||||
Shows:
|
||||
|
||||
- Density mode.
|
||||
- Theme accents.
|
||||
- Preferred season.
|
||||
- Pinned drivers.
|
||||
- API key status.
|
||||
- Data/cache path.
|
||||
- Live alert preferences.
|
||||
|
||||
## Navigation Model
|
||||
|
||||
Primary flow:
|
||||
|
||||
```text
|
||||
Season -> Weekend -> Session / Race Hub
|
||||
```
|
||||
|
||||
Live shortcut:
|
||||
|
||||
```text
|
||||
Command Center -> Live Timing -> Track / Battles / Pit Window / Race Control
|
||||
```
|
||||
|
||||
Data/support flow:
|
||||
|
||||
```text
|
||||
Data Library -> ingestion status / missing data
|
||||
```
|
||||
|
||||
Candidate routes:
|
||||
|
||||
- `/`
|
||||
- `/season/:year`
|
||||
- `/weekend/:meetingKey`
|
||||
- `/session/:sessionKey`
|
||||
- `/live`
|
||||
- `/drivers`
|
||||
- `/drivers/:driverNumber`
|
||||
- `/standings/:year`
|
||||
- `/data`
|
||||
- `/settings`
|
||||
|
||||
## Responsive Expectations
|
||||
|
||||
Phone:
|
||||
|
||||
- Stacked panels.
|
||||
- Sticky session/status header.
|
||||
- Bottom navigation.
|
||||
- Swipeable live panels.
|
||||
- Compact timing rows.
|
||||
|
||||
iPad:
|
||||
|
||||
- Split-pane layout.
|
||||
- Timing plus side panel.
|
||||
- Touch-friendly controls.
|
||||
- Comfortable chart inspection.
|
||||
|
||||
Desktop:
|
||||
|
||||
- Dense multi-column operations layout.
|
||||
- Persistent side panels.
|
||||
- More simultaneous context.
|
||||
|
||||
Density modes should influence row height, visible columns, chart spacing, and
|
||||
panel compactness.
|
||||
|
||||
152
documentations/refactor/05-frontend-stack.md
Normal file
152
documentations/refactor/05-frontend-stack.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Frontend Stack
|
||||
|
||||
## Summary
|
||||
|
||||
The next Web UI should move from embedded Alpine/static assets to a real React
|
||||
application. The target is a local-first, data-heavy, live-updating race
|
||||
dashboard served by the Go backend.
|
||||
|
||||
## Current Web UI
|
||||
|
||||
Current stack:
|
||||
|
||||
- Go `net/http` server.
|
||||
- Go `embed.FS` static assets.
|
||||
- Plain HTML/CSS/JavaScript.
|
||||
- Alpine.js from CDN.
|
||||
- D3.js from CDN.
|
||||
- Hash routing.
|
||||
- Raw `fetch`.
|
||||
- `EventSource` for live SSE.
|
||||
- No frontend build system.
|
||||
- No TypeScript.
|
||||
- No package-managed frontend dependencies.
|
||||
|
||||
This is a good prototype shape but not a good long-term foundation for the
|
||||
planned Web UI.
|
||||
|
||||
## Recommended Stack
|
||||
|
||||
### Vite
|
||||
|
||||
Purpose:
|
||||
|
||||
- Frontend dev server.
|
||||
- Fast TypeScript build.
|
||||
- Production asset bundling.
|
||||
- Clean integration with Go embedded static assets.
|
||||
|
||||
### React
|
||||
|
||||
Purpose:
|
||||
|
||||
- Component model for complex screens.
|
||||
- Good fit for live timing, charts, tables, filters, replay controls, and
|
||||
persistent interaction state.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Purpose:
|
||||
|
||||
- Stronger contracts for OpenF1, local API, and live timing payloads.
|
||||
- Safer refactors.
|
||||
- Better developer experience across data-heavy UI.
|
||||
|
||||
### TanStack Query
|
||||
|
||||
Purpose:
|
||||
|
||||
- Server-state fetching and caching.
|
||||
- Loading/error/stale states.
|
||||
- Background refresh.
|
||||
- Clear handling of local DB data, API fallback, and partial data.
|
||||
|
||||
### Router
|
||||
|
||||
Preferred candidates:
|
||||
|
||||
- TanStack Router for stronger type safety.
|
||||
- React Router if simplicity and familiarity matter more.
|
||||
|
||||
Routes should model product workflows rather than mimic current hash routing.
|
||||
|
||||
### D3
|
||||
|
||||
Purpose:
|
||||
|
||||
- Bespoke F1 visuals:
|
||||
- Strategy charts.
|
||||
- Track maps.
|
||||
- Position evolution.
|
||||
- Lap-time comparison.
|
||||
- Gap history.
|
||||
- Telemetry traces.
|
||||
|
||||
D3 should be used where the visual is genuinely custom. Simpler chart libraries
|
||||
can be considered later for generic charts.
|
||||
|
||||
### Zustand
|
||||
|
||||
Optional.
|
||||
|
||||
Purpose:
|
||||
|
||||
- Local UI preferences and cross-screen client state:
|
||||
- Pinned drivers.
|
||||
- Density mode.
|
||||
- Selected comparison drivers.
|
||||
- Visible live panels.
|
||||
- Replay speed.
|
||||
|
||||
Avoid adding it until React state and URL state become awkward.
|
||||
|
||||
### Testing
|
||||
|
||||
Vitest:
|
||||
|
||||
- Formatting helpers.
|
||||
- Data transforms.
|
||||
- Race calculations.
|
||||
- Chart input shaping.
|
||||
|
||||
Playwright:
|
||||
|
||||
- Page routing.
|
||||
- Race Hub rendering.
|
||||
- Live SSE behavior with mocked events.
|
||||
- Responsive layouts.
|
||||
- Data Library states.
|
||||
|
||||
## Why Not Astro As The App Shell
|
||||
|
||||
Astro is excellent when pages are mostly static and only specific islands need
|
||||
JavaScript. `box-box` is primarily an interactive application:
|
||||
|
||||
- Live timing updates.
|
||||
- SSE streams.
|
||||
- Dense tables.
|
||||
- Replay scrubbers.
|
||||
- Driver pinning.
|
||||
- Interactive charts.
|
||||
- Local-first data states.
|
||||
|
||||
Astro could wrap React islands, but most important screens would become React
|
||||
islands anyway. That adds split architecture without much benefit for this app.
|
||||
|
||||
Astro may still be useful for:
|
||||
|
||||
- Public docs.
|
||||
- A marketing/project site.
|
||||
- Static release notes.
|
||||
|
||||
For the product UI, Vite + React + TypeScript is the cleaner fit.
|
||||
|
||||
## Build Integration
|
||||
|
||||
Target behavior:
|
||||
|
||||
- During frontend development, Vite serves the React app.
|
||||
- During normal `go run cmd/main.go --web`, Go serves compiled frontend assets.
|
||||
- The backend remains responsible for SQLite, ingestion, OpenF1, SignalR, REST,
|
||||
and SSE.
|
||||
|
||||
123
documentations/refactor/06-visual-design-direction.md
Normal file
123
documentations/refactor/06-visual-design-direction.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Visual Design Direction
|
||||
|
||||
## Summary
|
||||
|
||||
The visual direction should be F1-native without falling into generic dashboard
|
||||
habits. The app should feel like an operations room for following a race
|
||||
weekend: technical, fast, sharp, and legible. It should avoid AI-slop patterns
|
||||
such as endless decorative cards, vague gradient panels, giant generic hero
|
||||
sections, and meaningless visual chrome.
|
||||
|
||||
## Design North Star
|
||||
|
||||
Use the phrase "F1 Ops Room" as the working direction.
|
||||
|
||||
Qualities:
|
||||
|
||||
- Dense but controlled.
|
||||
- High signal.
|
||||
- Fast to scan.
|
||||
- Precise typography.
|
||||
- Strong hierarchy.
|
||||
- Team color used as information, not decoration.
|
||||
- Good on phone and iPad, not just desktop.
|
||||
|
||||
## Density
|
||||
|
||||
Density should be configurable:
|
||||
|
||||
- Compact: timing-wall mode, maximum data per viewport.
|
||||
- Comfortable: default mode for most users.
|
||||
- Touch: larger hit targets and panel spacing for phone/iPad.
|
||||
|
||||
Density affects:
|
||||
|
||||
- Table row height.
|
||||
- Visible columns.
|
||||
- Panel spacing.
|
||||
- Chart label detail.
|
||||
- Header size.
|
||||
- Control grouping.
|
||||
|
||||
## Timing-Wall Ergonomics
|
||||
|
||||
Live timing should prioritize scan speed:
|
||||
|
||||
- Position and driver identity must be easy to locate.
|
||||
- Gap/interval changes should be visually distinct.
|
||||
- Pit state, retired state, and tyre state should be obvious.
|
||||
- Race-control alerts should interrupt without becoming noisy.
|
||||
- Pinned drivers should remain available across live views.
|
||||
|
||||
## Team Color Discipline
|
||||
|
||||
Team colors are useful data, but they can quickly become visual noise.
|
||||
|
||||
Rules:
|
||||
|
||||
- Use team color for identity markers, row accents, chart lines, and selected
|
||||
driver focus.
|
||||
- Avoid flooding large surfaces with saturated team color.
|
||||
- Always preserve contrast and legibility.
|
||||
- Avoid making the whole interface a rainbow unless the context is explicitly
|
||||
comparative.
|
||||
|
||||
## Layout Principles
|
||||
|
||||
Prefer:
|
||||
|
||||
- Full-width information bands.
|
||||
- Dense tables with strong alignment.
|
||||
- Split panes.
|
||||
- Sticky session headers.
|
||||
- Bottom navigation on phone.
|
||||
- Clear panel switching on smaller screens.
|
||||
- Charts that explain race state, not just decorate.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Card sludge: every concept boxed into a decorative card.
|
||||
- Floating cards inside cards.
|
||||
- Generic SaaS dashboard grids.
|
||||
- Purple/blue gradient panels with no product meaning.
|
||||
- Decorative orbs, bokeh, or random glow effects.
|
||||
- Vague hero sections.
|
||||
- Overly large typography inside operational surfaces.
|
||||
|
||||
## F1-Native References To Research
|
||||
|
||||
Research should study:
|
||||
|
||||
- Official F1 timing tower ergonomics.
|
||||
- Broadcast graphics hierarchy.
|
||||
- FIA timing/result sheet density.
|
||||
- Race control message formatting.
|
||||
- Pit wall and telemetry workstation patterns.
|
||||
- Motorsport data overlays.
|
||||
|
||||
The goal is not to copy official F1 branding. The goal is to understand the
|
||||
information hierarchy and pacing of motorsport interfaces.
|
||||
|
||||
## Mobile And iPad
|
||||
|
||||
The app should work well on phone and iPad because those are likely primary
|
||||
second-screen devices during race sessions.
|
||||
|
||||
Phone:
|
||||
|
||||
- Prioritize Live Timing, alerts, pinned drivers, and quick switching.
|
||||
- Use stacked panels and sticky status.
|
||||
- Keep interactions thumb-friendly.
|
||||
|
||||
iPad:
|
||||
|
||||
- Use two-pane and three-pane layouts.
|
||||
- Keep charts inspectable.
|
||||
- Make side panels easy to swap.
|
||||
|
||||
Desktop:
|
||||
|
||||
- Allow dense multi-panel layouts.
|
||||
- Show more simultaneous context.
|
||||
- Preserve keyboard and pointer efficiency.
|
||||
|
||||
206
documentations/refactor/07-research-agents-brief.md
Normal file
206
documentations/refactor/07-research-agents-brief.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Research Agents Brief
|
||||
|
||||
## Summary
|
||||
|
||||
Before implementation tickets are written, dedicated research agents should
|
||||
investigate the uncertain parts of the refactor. Their outputs should feed a
|
||||
product/architecture planning pass that turns findings into phased work.
|
||||
|
||||
Each research track should separate confirmed facts, assumptions, risks, and
|
||||
recommendations.
|
||||
|
||||
## 1. OpenF1 Contract Research
|
||||
|
||||
Objective:
|
||||
|
||||
- Document the exact OpenF1 endpoint contract needed by `box-box`.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Existing `internal/api` client.
|
||||
- OpenF1 docs: https://openf1.org/docs/
|
||||
- Current app screens and planned Race Hub requirements.
|
||||
|
||||
Outputs:
|
||||
|
||||
- Endpoint inventory.
|
||||
- Field/schema notes.
|
||||
- Update cadence by endpoint.
|
||||
- Auth/free-tier behavior.
|
||||
- Rate-limit and lockout notes.
|
||||
- Essential vs optional datasets for v1.
|
||||
|
||||
Key questions:
|
||||
|
||||
- Which endpoints are immutable after session completion?
|
||||
- Which endpoints are high-volume enough to require explicit ingestion?
|
||||
- What errors are returned during live-session lockout?
|
||||
- Which endpoints can be filtered to reduce ingestion cost?
|
||||
|
||||
## 2. Official F1 Live Timing Research
|
||||
|
||||
Objective:
|
||||
|
||||
- Document the SignalR live feed contract and parser risks.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Current `internal/ui/official_live.go`.
|
||||
- SignalR endpoint: https://livetiming.formula1.com/signalr
|
||||
- OpenF1.Data package notes:
|
||||
https://www.nuget.org/packages/OpenF1.Data/1.0.87
|
||||
|
||||
Outputs:
|
||||
|
||||
- Topic inventory.
|
||||
- Payload examples where available.
|
||||
- Parser fragility notes.
|
||||
- Recommended domain event/state model.
|
||||
- Testing strategy for non-live periods.
|
||||
|
||||
Key questions:
|
||||
|
||||
- Are current subscribed topics sufficient for the planned Web live mode?
|
||||
- Which topics should be parsed as events vs current state?
|
||||
- How should disconnections and reconnections be represented?
|
||||
- Should live snapshots/events be persisted?
|
||||
|
||||
## 3. Static Archive Feasibility Research
|
||||
|
||||
Objective:
|
||||
|
||||
- Determine whether official F1 static archived timing files should become a
|
||||
supported source.
|
||||
|
||||
Inputs:
|
||||
|
||||
- LiveF1 data topic reference:
|
||||
https://livef1.goktugocal.com/livetimingf1/data_topics.html
|
||||
- Public static archive URL patterns.
|
||||
- OpenF1 meeting/session metadata.
|
||||
|
||||
Outputs:
|
||||
|
||||
- Feasibility assessment.
|
||||
- Session path mapping strategy.
|
||||
- Available years/session types.
|
||||
- Topic/file inventory.
|
||||
- Risks and legal/operational considerations.
|
||||
|
||||
Key questions:
|
||||
|
||||
- Can OpenF1 sessions be mapped reliably to static archive paths?
|
||||
- Are static archive files available consistently?
|
||||
- Which files provide replay-quality timing?
|
||||
- Is this source stable enough for v1 or later only?
|
||||
|
||||
## 4. SQLite Schema And Indexing Design
|
||||
|
||||
Objective:
|
||||
|
||||
- Turn the domain database design into a concrete schema proposal.
|
||||
|
||||
Inputs:
|
||||
|
||||
- `03-database-design.md`.
|
||||
- Existing `internal/models/types.go`.
|
||||
- Race Hub and Live Replay query requirements.
|
||||
|
||||
Outputs:
|
||||
|
||||
- Table definitions.
|
||||
- Primary keys and foreign keys.
|
||||
- Index proposal.
|
||||
- Raw payload strategy.
|
||||
- Migration strategy.
|
||||
- High-volume data retention recommendations.
|
||||
|
||||
Design questions:
|
||||
|
||||
- Which tables need composite primary keys?
|
||||
- Which read paths need covering indexes?
|
||||
- Should telemetry/location be optional datasets?
|
||||
- Should derived read-model tables exist in v1?
|
||||
|
||||
## 5. Backend API And Read-Model Design
|
||||
|
||||
Objective:
|
||||
|
||||
- Design the Web API shape that React will consume.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Existing `internal/web/api.go`.
|
||||
- Planned Web screens.
|
||||
- Store/query requirements.
|
||||
|
||||
Outputs:
|
||||
|
||||
- Endpoint proposal.
|
||||
- Response envelope proposal.
|
||||
- Source/staleness metadata shape.
|
||||
- Error/partial-data behavior.
|
||||
- Migration strategy from existing endpoints.
|
||||
|
||||
Design questions:
|
||||
|
||||
- Should existing `/api/v1` routes be preserved and expanded?
|
||||
- What metadata should every response include?
|
||||
- How should partial data be represented?
|
||||
- Which read models should be backend-computed vs frontend-computed?
|
||||
|
||||
## 6. F1-Native Visual System Research
|
||||
|
||||
Objective:
|
||||
|
||||
- Produce visual principles and examples for the React UI before components are
|
||||
built.
|
||||
|
||||
Inputs:
|
||||
|
||||
- `06-visual-design-direction.md`.
|
||||
- F1 broadcast timing graphics.
|
||||
- FIA timing/result sheets.
|
||||
- Motorsport telemetry and timing tools.
|
||||
|
||||
Outputs:
|
||||
|
||||
- Moodboard or written reference guide.
|
||||
- Layout principles.
|
||||
- Typography and density guidance.
|
||||
- Color usage rules.
|
||||
- Anti-pattern list.
|
||||
|
||||
Key questions:
|
||||
|
||||
- How should the app look F1-native without copying official branding?
|
||||
- What visual hierarchy makes live timing fastest to scan?
|
||||
- How should phone/iPad layouts differ from desktop?
|
||||
- How can the UI avoid generic card-heavy dashboard design?
|
||||
|
||||
## 7. Testing Strategy Research
|
||||
|
||||
Objective:
|
||||
|
||||
- Define a test strategy for backend, ingestion, frontend, and live behavior.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Existing tests.
|
||||
- Planned store/ingestion architecture.
|
||||
- Live feed limitations outside active sessions.
|
||||
|
||||
Outputs:
|
||||
|
||||
- Backend unit/integration test plan.
|
||||
- Ingestion fixture strategy.
|
||||
- Frontend Vitest and Playwright strategy.
|
||||
- Mock SSE/live fixture plan.
|
||||
- Manual acceptance checklist.
|
||||
|
||||
Key questions:
|
||||
|
||||
- How should live SignalR behavior be tested without an active session?
|
||||
- What source payload fixtures are needed?
|
||||
- Which scenarios require real OpenF1 integration tests?
|
||||
- How should local DB migrations be tested?
|
||||
186
documentations/refactor/08-v1-scope-and-phasing.md
Normal file
186
documentations/refactor/08-v1-scope-and-phasing.md
Normal 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.
|
||||
195
documentations/refactor/09-phase-1-live-extraction.md
Normal file
195
documentations/refactor/09-phase-1-live-extraction.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Phase 1 Live Extraction
|
||||
|
||||
## Purpose
|
||||
|
||||
Phase 1 creates a stable live timing foundation without changing the product
|
||||
surface. The current live mode is the strongest part of `box-box`, but the core
|
||||
SignalR connection and parsing code lives inside `internal/ui`. That creates a
|
||||
bad dependency direction: the Web server imports TUI code only to access live
|
||||
data types and `ConnectToF1LiveTiming`.
|
||||
|
||||
The goal is to extract the reusable live timing core into `internal/live`, keep
|
||||
the TUI and Web UI working, and add fixture-based tests around the parsing
|
||||
surface. This is a foundation phase, not a frontend redesign phase.
|
||||
|
||||
## Manager Decision
|
||||
|
||||
I agree with Claude that Race Hub is the safest first React product slice.
|
||||
However, before React work starts, the live timing backend should be separated
|
||||
from the TUI. The current Web UI already depends on live data through SSE, and
|
||||
future React live screens will need that source without importing terminal UI
|
||||
code.
|
||||
|
||||
Therefore Phase 1 is:
|
||||
|
||||
- Extract the live SignalR bridge into `internal/live`.
|
||||
- Update TUI live mode to consume `internal/live`.
|
||||
- Update Web SSE live mode to consume `internal/live`.
|
||||
- Add tests for live message parsing/state updates.
|
||||
- Do not add persistence, React, or new UI behavior yet.
|
||||
|
||||
## Current Coupling To Remove
|
||||
|
||||
Current state:
|
||||
|
||||
- `internal/ui/official_live.go` owns SignalR protocol types, live data types,
|
||||
topic parsing, connection setup, and TUI rendering.
|
||||
- `internal/web/live.go` imports `internal/ui` for `ui.LiveStreamData` and
|
||||
`ui.ConnectToF1LiveTiming`.
|
||||
|
||||
Target state:
|
||||
|
||||
- `internal/live` owns reusable live data structures, SignalR protocol parsing,
|
||||
connection setup, and state update logic.
|
||||
- `internal/ui` owns Bubble Tea model state, keyboard behavior, and terminal
|
||||
rendering.
|
||||
- `internal/web` owns SSE clients, HTTP handlers, reconnect/backoff policy, and
|
||||
JSON responses.
|
||||
|
||||
## Proposed Package Boundary
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
internal/live/
|
||||
types.go LiveStreamData, LiveDriverData, weather, race control, tyres
|
||||
signalr.go negotiate/connect/subscribe to official F1 SignalR
|
||||
parser.go raw message parsing and topic dispatch
|
||||
state.go mutable live state accumulator and snapshot copying
|
||||
parser_test.go fixture-driven tests
|
||||
testdata/ small captured/synthetic SignalR messages
|
||||
```
|
||||
|
||||
The exact file split can change during implementation, but the boundary should
|
||||
stay clear: `internal/live` must not import `internal/ui` or Bubble Tea.
|
||||
|
||||
## API Shape
|
||||
|
||||
Keep a small API compatible with current callers:
|
||||
|
||||
```go
|
||||
package live
|
||||
|
||||
type StreamData = LiveStreamData // or a normal exported type if clearer
|
||||
|
||||
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error
|
||||
```
|
||||
|
||||
Optional improvements are allowed only if they stay small and do not force broad
|
||||
behavior changes:
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
// future room for custom http client, logger, topic list, clock, etc.
|
||||
}
|
||||
|
||||
func (c *Client) Connect(dataChan chan LiveStreamData) error
|
||||
```
|
||||
|
||||
If a `Client` is introduced, preserve the top-level
|
||||
`ConnectToF1LiveTiming(dataChan)` as a convenience wrapper so TUI and Web changes
|
||||
remain boring.
|
||||
|
||||
## What Moves From `internal/ui/official_live.go`
|
||||
|
||||
Move or duplicate-then-delete these reusable concerns into `internal/live`:
|
||||
|
||||
- `F1SignalRMessage`
|
||||
- `F1TimingLine`
|
||||
- `F1DriverListEntry`
|
||||
- `LiveTyreData`
|
||||
- `LiveRCMessage`
|
||||
- `LiveWeatherData`
|
||||
- `LiveSessionMeta`
|
||||
- `LiveSectorData`
|
||||
- `LiveDriverData`
|
||||
- `LiveStintData`
|
||||
- `LiveStreamData`
|
||||
- `ConnectToF1LiveTiming`
|
||||
- topic parsing and state accumulation helpers currently embedded in the
|
||||
connection goroutine
|
||||
- snapshot-copying logic used before sending updates
|
||||
|
||||
Keep these TUI-specific concerns in `internal/ui/official_live.go`:
|
||||
|
||||
- `OfficialLiveModel`
|
||||
- Bubble Tea messages and commands
|
||||
- viewport handling
|
||||
- keybindings
|
||||
- terminal render functions
|
||||
- battle/pit-window display logic unless it is already pure and clearly useful
|
||||
to share
|
||||
|
||||
## Tests
|
||||
|
||||
Live sessions are not always available, so Phase 1 tests must not depend on a
|
||||
current race weekend. Add fixture-based tests in `internal/live`.
|
||||
|
||||
Minimum test coverage:
|
||||
|
||||
- Parse a SignalR `R` full-state message.
|
||||
- Parse a SignalR `M` incremental update message.
|
||||
- Handle known topics without panicking:
|
||||
- `TimingData`
|
||||
- `DriverList`
|
||||
- `LapCount`
|
||||
- `ExtrapolatedClock`
|
||||
- `TrackStatus`
|
||||
- `RaceControlMessages`
|
||||
- `WeatherData`
|
||||
- `SessionInfo`
|
||||
- `CurrentTyres`
|
||||
- `TimingAppData`
|
||||
- `TimingStats`
|
||||
- Preserve existing string/float/nested-value handling in timing fields.
|
||||
- Ignore unknown topics without failing.
|
||||
- Verify snapshots copy maps/slices so downstream consumers cannot mutate
|
||||
internal accumulator state accidentally.
|
||||
|
||||
Fixtures can be small synthetic messages shaped like the official feed. They do
|
||||
not need to be full captured race payloads.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Phase 1 is complete when:
|
||||
|
||||
- `internal/web/live.go` no longer imports `internal/ui`.
|
||||
- `internal/ui/official_live.go` compiles while consuming `internal/live`.
|
||||
- The existing TUI live mode still uses the official F1 SignalR feed.
|
||||
- The existing Web live SSE path still uses the official F1 SignalR feed.
|
||||
- `go test ./...` passes.
|
||||
- Parser tests run without internet access.
|
||||
- No local database, React, or visual redesign work has been started as part of
|
||||
this phase.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not include these in Phase 1:
|
||||
|
||||
- React/Vite frontend setup.
|
||||
- SQLite domain database or migrations.
|
||||
- OpenF1 ingestion refactor.
|
||||
- Live event persistence.
|
||||
- Race Hub implementation.
|
||||
- Static archive research.
|
||||
- Browser notification work.
|
||||
- Major rewrite of TUI live rendering.
|
||||
|
||||
## Risks And Guardrails
|
||||
|
||||
- The live parser currently works in practice; avoid clever rewrites that change
|
||||
behavior without tests.
|
||||
- Official F1 SignalR topic schemas can drift. Keep parsing tolerant of missing,
|
||||
empty, string, numeric, and nested values.
|
||||
- Do not make Web reconnect/backoff policy part of `internal/live` yet. The Web
|
||||
server can keep owning that operational behavior.
|
||||
- Do not make the TUI import Web code. Shared logic should flow through
|
||||
`internal/live`.
|
||||
- Preserve existing logs and user-facing behavior unless a small compile-time
|
||||
adjustment requires otherwise.
|
||||
|
||||
## Next Phase After This
|
||||
|
||||
After Phase 1, Phase 2 should start `internal/store` and the local SQLite domain
|
||||
database. Live persistence should still wait until the live data/event types have
|
||||
settled and the database provenance design is ready.
|
||||
136
documentations/refactor/10-phase-2-store-foundation.md
Normal file
136
documentations/refactor/10-phase-2-store-foundation.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Phase 2 Store Foundation
|
||||
|
||||
## Purpose
|
||||
|
||||
Phase 2 introduces the local domain database foundation. The current SQLite
|
||||
database is an HTTP response cache. That should remain intact, but it is not the
|
||||
same thing as an app-owned F1 domain store.
|
||||
|
||||
The goal of this phase is to add `internal/store` with schema initialization,
|
||||
migrations, provenance-aware raw payload storage, and a small set of typed
|
||||
domain tables needed by Race Hub v1. This phase should not build ingestion
|
||||
commands or change the Web UI yet.
|
||||
|
||||
## Manager Decision
|
||||
|
||||
Keep this phase boring and structural. Do not try to ingest a full weekend yet.
|
||||
The deliverable is a tested store package that later phases can call.
|
||||
|
||||
Phase 2 should prove:
|
||||
|
||||
- the app can create/open a domain SQLite database;
|
||||
- migrations are repeatable and idempotent;
|
||||
- raw source payloads can be stored with provenance;
|
||||
- basic meeting/session/driver/session result records can be upserted and read;
|
||||
- existing HTTP cache behavior is untouched.
|
||||
|
||||
## Package Boundary
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
internal/store/
|
||||
db.go open/close database, pragmas, transaction helper
|
||||
migrations.go embedded SQL migrations and schema versioning
|
||||
models.go store-layer structs for v1 domain records
|
||||
raw.go raw payload/provenance writes and reads
|
||||
meetings.go typed meeting/session upserts and reads
|
||||
results.go typed driver/result/grid-style records as initial slice
|
||||
store_test.go temp-db migration and CRUD tests
|
||||
```
|
||||
|
||||
The exact file split can change, but `internal/store` should not import
|
||||
`internal/ui` or `internal/web`.
|
||||
|
||||
## Database Location
|
||||
|
||||
Use a conservative default path separate from the existing HTTP cache:
|
||||
|
||||
```text
|
||||
~/.local/share/box-box/boxbox.db
|
||||
```
|
||||
|
||||
Tests must use temporary databases, not the user's real home directory.
|
||||
|
||||
## Initial Schema Scope
|
||||
|
||||
Create tables for:
|
||||
|
||||
- `schema_migrations`
|
||||
- `raw_payloads`
|
||||
- `ingestion_runs`
|
||||
- `meetings`
|
||||
- `sessions`
|
||||
- `drivers`
|
||||
- `session_drivers`
|
||||
- `session_results`
|
||||
- `starting_grid`
|
||||
|
||||
It is acceptable to include additional Race Hub v1 tables if doing so is
|
||||
straightforward, but do not overbuild high-volume telemetry yet.
|
||||
|
||||
## Raw Payload Strategy
|
||||
|
||||
`raw_payloads` should preserve source truth before normalization.
|
||||
|
||||
Recommended columns:
|
||||
|
||||
- source name, such as `openf1`
|
||||
- endpoint or topic
|
||||
- request key or URL
|
||||
- meeting key when known
|
||||
- session key when known
|
||||
- payload JSON text/blob
|
||||
- payload hash
|
||||
- fetched timestamp
|
||||
- provenance metadata JSON
|
||||
|
||||
Raw payload storage should be idempotent by source/request/hash or another
|
||||
clear uniqueness rule.
|
||||
|
||||
## Domain Table Strategy
|
||||
|
||||
Use stable OpenF1 identifiers where available:
|
||||
|
||||
- `meeting_key`
|
||||
- `session_key`
|
||||
- `driver_number`
|
||||
|
||||
Prefer explicit upserts over blind inserts. Completed historical data should be
|
||||
safe to re-run without duplicating rows.
|
||||
|
||||
## Tests
|
||||
|
||||
Minimum tests:
|
||||
|
||||
- opening a temp database applies migrations;
|
||||
- migrations can be run twice;
|
||||
- schema version is recorded;
|
||||
- raw payload insert/read works and preserves provenance;
|
||||
- duplicate raw payload writes do not create accidental duplicates;
|
||||
- meeting/session/driver/result upserts are idempotent;
|
||||
- basic Race Hub read helpers can retrieve inserted meeting/session/result data.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not include these in Phase 2:
|
||||
|
||||
- OpenF1 backfill orchestration.
|
||||
- CLI ingestion commands.
|
||||
- Web UI changes.
|
||||
- React setup.
|
||||
- Replacing existing `internal/api/cache.go`.
|
||||
- High-volume telemetry tables for car data/location.
|
||||
- Live SignalR persistence.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Phase 2 is complete when:
|
||||
|
||||
- `internal/store` exists with tested migration and CRUD behavior.
|
||||
- The package can create a fresh SQLite domain database.
|
||||
- Running migrations repeatedly is safe.
|
||||
- Store tests pass without internet access.
|
||||
- `go test ./internal/store/...` passes.
|
||||
- `go test ./...` either passes or only fails because existing OpenF1
|
||||
integration tests cannot reach the network/API.
|
||||
163
documentations/refactor/11-phase-3-ingestion-foundation.md
Normal file
163
documentations/refactor/11-phase-3-ingestion-foundation.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Phase 3 Ingestion Foundation
|
||||
|
||||
## Purpose
|
||||
|
||||
Phase 3 connects OpenF1 REST data to the local domain store introduced in Phase
|
||||
2. The goal is to ingest a meeting or session intentionally, record provenance,
|
||||
write raw payloads, normalize the initial Race Hub datasets, and make the work
|
||||
idempotent and resumable.
|
||||
|
||||
This phase should still avoid Web UI replacement work. It creates the backend
|
||||
path that later Race Hub APIs and React screens can trust.
|
||||
|
||||
## Manager Decision
|
||||
|
||||
Build ingestion as an explicit backend workflow first, not as an automatic Web
|
||||
side effect. Normal browsing must not accidentally trigger a full weekend
|
||||
backfill or burn through API quota.
|
||||
|
||||
Phase 3 should add:
|
||||
|
||||
- `internal/ingest` orchestration.
|
||||
- OpenF1 source-to-store mapping for the Phase 2 tables.
|
||||
- A small CLI command path for manual ingestion.
|
||||
- Dry-run and progress output.
|
||||
- conservative retry/rate-limit behavior.
|
||||
|
||||
## Package Boundary
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
internal/ingest/
|
||||
ingest.go orchestrator, options, result summary
|
||||
openf1.go OpenF1 source adapter and model mapping
|
||||
progress.go progress event/output helpers if useful
|
||||
ingest_test.go fake-source/fake-store or temp-db tests
|
||||
```
|
||||
|
||||
The package should depend on:
|
||||
|
||||
- `internal/api` for OpenF1 reads;
|
||||
- `internal/store` for writes;
|
||||
- `internal/models` for current OpenF1 response structs.
|
||||
|
||||
It should not depend on:
|
||||
|
||||
- `internal/ui`;
|
||||
- `internal/web`;
|
||||
- React/frontend code.
|
||||
|
||||
## Initial Ingestion Scope
|
||||
|
||||
Support these commands/workflows first:
|
||||
|
||||
- ingest meetings for a year;
|
||||
- ingest sessions for a meeting;
|
||||
- ingest a single session's Race Hub v1 datasets.
|
||||
|
||||
For a race session, ingest:
|
||||
|
||||
- meeting metadata when available;
|
||||
- session metadata;
|
||||
- drivers;
|
||||
- session result;
|
||||
- starting grid;
|
||||
- raw payload records for each fetched endpoint.
|
||||
|
||||
If Cursor chooses to include laps, stints, pits, race control, or weather, the
|
||||
store schema must support them first. Otherwise leave those datasets for Phase
|
||||
4 or a Phase 3 follow-up. Do not jam JSON blobs into unrelated tables just to
|
||||
claim coverage.
|
||||
|
||||
## CLI Shape
|
||||
|
||||
Extend `cmd/main.go` conservatively. Keep the default TUI and `--web` behavior
|
||||
unchanged.
|
||||
|
||||
Recommended flags:
|
||||
|
||||
```bash
|
||||
go run cmd/main.go --ingest-year 2025
|
||||
go run cmd/main.go --ingest-meeting 1229
|
||||
go run cmd/main.go --ingest-session 9472
|
||||
go run cmd/main.go --ingest-session 9472 --dry-run
|
||||
go run cmd/main.go --ingest-session 9472 --db /path/to/boxbox.db
|
||||
```
|
||||
|
||||
This is acceptable as a first CLI slice. A richer subcommand framework can wait.
|
||||
|
||||
## Ingestion Behavior
|
||||
|
||||
Defaults:
|
||||
|
||||
- sequential requests;
|
||||
- small delay between endpoint calls;
|
||||
- bounded retry for transient failures;
|
||||
- stop cleanly on OpenF1 live-session lockout;
|
||||
- no silent full-season backfills;
|
||||
- print progress and final summary;
|
||||
- write raw payload provenance for each endpoint;
|
||||
- upsert normalized records so reruns are safe.
|
||||
|
||||
## Raw Payload Provenance
|
||||
|
||||
Each fetched endpoint should record:
|
||||
|
||||
- source: `openf1`;
|
||||
- endpoint name;
|
||||
- request key;
|
||||
- meeting key when known;
|
||||
- session key when known;
|
||||
- fetched timestamp;
|
||||
- raw JSON payload;
|
||||
- HTTP/API provenance when available;
|
||||
- whether data came from stale cache if that signal is available.
|
||||
|
||||
If the current API client does not expose raw JSON easily, prefer a small source
|
||||
adapter enhancement over duplicating HTTP logic wildly. Keep existing cache
|
||||
behavior intact.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests should avoid real network calls.
|
||||
|
||||
Minimum tests:
|
||||
|
||||
- ingesting a fake session writes drivers, results, grid rows, and raw payloads;
|
||||
- rerunning the same ingestion does not duplicate normalized rows;
|
||||
- dry-run does not write domain rows;
|
||||
- source errors stop the run and record/report failure;
|
||||
- live-session lockout is surfaced as a controlled failure;
|
||||
- CLI flag parsing does not break default TUI/Web behavior if covered cheaply.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not include these in Phase 3:
|
||||
|
||||
- React/Vite frontend implementation.
|
||||
- Web Race Hub API replacement.
|
||||
- automatic Web-triggered backfill.
|
||||
- live SignalR persistence.
|
||||
- full-season default backfill.
|
||||
- static archive ingestion.
|
||||
- high-volume car telemetry ingestion.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Phase 3 is complete when:
|
||||
|
||||
- `internal/ingest` exists and is covered by offline tests.
|
||||
- A user can manually ingest a year, meeting, or session from the CLI.
|
||||
- Rerunning ingestion is idempotent.
|
||||
- Raw payloads and normalized records are both written.
|
||||
- `go test ./internal/ingest/... ./internal/store/...` passes.
|
||||
- `go build -o /tmp/box-box ./cmd/main.go` passes.
|
||||
- `go test ./...` either passes or only fails because existing OpenF1
|
||||
integration tests cannot reach the network/API.
|
||||
|
||||
## Next Phase After This
|
||||
|
||||
Phase 4 should add local-first backend read models and Web API endpoints for
|
||||
Race Hub v1. It should make the Web API prefer local SQLite data and report
|
||||
missing datasets honestly.
|
||||
139
documentations/refactor/12-phase-4-local-first-web-api.md
Normal file
139
documentations/refactor/12-phase-4-local-first-web-api.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Phase 4 Local-First Web API
|
||||
|
||||
## Purpose
|
||||
|
||||
Phase 4 makes the Web API start behaving like a local-first product. Phases 2
|
||||
and 3 created the domain store and explicit ingestion path; this phase adds
|
||||
read models that prefer local SQLite data and report data availability honestly.
|
||||
|
||||
This is still a backend phase. Do not start React yet.
|
||||
|
||||
## Manager Decision
|
||||
|
||||
Build one credible local-first Race Hub API slice before replacing the frontend.
|
||||
The current Web UI can keep working from the existing endpoints, but the backend
|
||||
should expose store-backed responses that a future React Race Hub can trust.
|
||||
|
||||
Phase 4 should add:
|
||||
|
||||
- store-backed read models for ingested meetings, sessions, drivers, results,
|
||||
and grid;
|
||||
- dataset/status metadata so the UI knows what is local, missing, or stale;
|
||||
- optional small API fallbacks only when explicitly requested;
|
||||
- tests for local-first behavior without network.
|
||||
|
||||
## Package Boundary
|
||||
|
||||
Prefer adding a backend read-model layer instead of embedding SQL inside HTTP
|
||||
handlers.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```text
|
||||
internal/query/
|
||||
racehub.go Race Hub read model assembly
|
||||
metadata.go dataset availability/source metadata
|
||||
query_test.go temp-db tests
|
||||
```
|
||||
|
||||
Then wire `internal/web` to use that layer.
|
||||
|
||||
If the implementation keeps the read layer inside `internal/web` temporarily,
|
||||
it must still avoid duplicating store SQL across handlers.
|
||||
|
||||
## Initial API Scope
|
||||
|
||||
Add a new Race Hub endpoint:
|
||||
|
||||
```text
|
||||
GET /api/v1/race-hub?session_key=9472
|
||||
```
|
||||
|
||||
Response should include:
|
||||
|
||||
- meeting;
|
||||
- session;
|
||||
- drivers;
|
||||
- session results enriched with driver/team fields;
|
||||
- starting grid enriched with driver/team fields;
|
||||
- dataset availability metadata.
|
||||
|
||||
Recommended metadata shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "local",
|
||||
"session_key": 9472,
|
||||
"datasets": {
|
||||
"meeting": {"status": "available", "source": "local"},
|
||||
"session": {"status": "available", "source": "local"},
|
||||
"drivers": {"status": "available", "source": "local", "count": 20},
|
||||
"results": {"status": "missing", "source": "none", "count": 0},
|
||||
"starting_grid": {"status": "available", "source": "local", "count": 20}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exact field names can vary, but the response must make missing datasets visible
|
||||
instead of silently returning empty app states.
|
||||
|
||||
## Existing Endpoint Policy
|
||||
|
||||
Do not rewrite every existing endpoint yet. It is enough to:
|
||||
|
||||
- add the new local-first Race Hub endpoint;
|
||||
- optionally make `/api/v1/meetings`, `/api/v1/sessions`, `/api/v1/drivers`,
|
||||
`/api/v1/results`, and `/api/v1/grid` read from local data when present;
|
||||
- preserve old OpenF1 behavior when local data is absent unless the request asks
|
||||
for local-only behavior.
|
||||
|
||||
Recommended query controls:
|
||||
|
||||
```text
|
||||
?source=local local only; no OpenF1 fallback
|
||||
?source=auto local first, existing OpenF1 fallback when missing
|
||||
```
|
||||
|
||||
Default should be conservative for existing endpoints. The new Race Hub endpoint
|
||||
can default to local-first with honest missing metadata.
|
||||
|
||||
## Server Wiring
|
||||
|
||||
`web.Server` currently only receives `*api.OpenF1Client`. Add an optional
|
||||
`*store.Store` or query service so Web mode can read the domain DB.
|
||||
|
||||
CLI/server behavior should remain simple:
|
||||
|
||||
```bash
|
||||
go run cmd/main.go --web
|
||||
go run cmd/main.go --web --db /path/to/boxbox.db
|
||||
```
|
||||
|
||||
If the DB does not exist or has no ingested data, Web mode should still start.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not include these in Phase 4:
|
||||
|
||||
- React/Vite frontend setup.
|
||||
- replacing the current static Web UI;
|
||||
- automatic ingestion from Web browsing;
|
||||
- live SignalR persistence;
|
||||
- laps/stints/pits/weather/race-control read models unless the store schema is
|
||||
expanded and tested first.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Phase 4 is complete when:
|
||||
|
||||
- a local-first Race Hub endpoint exists;
|
||||
- it can return ingested session data without OpenF1 calls;
|
||||
- it reports missing datasets explicitly;
|
||||
- Web mode can be pointed at a domain DB with `--db`;
|
||||
- offline tests cover the read model and HTTP handler behavior;
|
||||
- focused tests and build pass.
|
||||
|
||||
## Next Phase After This
|
||||
|
||||
Phase 5 is the first frontend implementation phase. That is the point to switch
|
||||
from Cursor to Claude for React/UI work.
|
||||
105
documentations/refactor/13-phase-5-react-race-hub.md
Normal file
105
documentations/refactor/13-phase-5-react-race-hub.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
65
documentations/refactor/16-phase-8-analytics-visuals.md
Normal file
65
documentations/refactor/16-phase-8-analytics-visuals.md
Normal 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.
|
||||
52
documentations/refactor/17-phase-9-navigation-data-api.md
Normal file
52
documentations/refactor/17-phase-9-navigation-data-api.md
Normal 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.
|
||||
47
documentations/refactor/18-phase-10-navigation-ui.md
Normal file
47
documentations/refactor/18-phase-10-navigation-ui.md
Normal 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.
|
||||
40
documentations/refactor/19-phase-11-weekend-ingestion.md
Normal file
40
documentations/refactor/19-phase-11-weekend-ingestion.md
Normal 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.
|
||||
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal 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.
|
||||
127
documentations/refactor/21-mvp-completion-checklist.md
Normal file
127
documentations/refactor/21-mvp-completion-checklist.md
Normal 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.
|
||||
55
documentations/refactor/22-phase-14-visual-regression.md
Normal file
55
documentations/refactor/22-phase-14-visual-regression.md
Normal 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)
|
||||
48
documentations/refactor/23-phase-15-command-center.md
Normal file
48
documentations/refactor/23-phase-15-command-center.md
Normal 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)
|
||||
43
documentations/refactor/24-phase-16-live-timing-polish.md
Normal file
43
documentations/refactor/24-phase-16-live-timing-polish.md
Normal 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)
|
||||
97
documentations/refactor/25-phase-18-fan-command-center.md
Normal file
97
documentations/refactor/25-phase-18-fan-command-center.md
Normal 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)
|
||||
128
documentations/refactor/26-phase-19-weekend-workspace.md
Normal file
128
documentations/refactor/26-phase-19-weekend-workspace.md
Normal 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)
|
||||
110
documentations/refactor/27-phase-19b-paddock-briefing-rss.md
Normal file
110
documentations/refactor/27-phase-19b-paddock-briefing-rss.md
Normal 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?
|
||||
235
documentations/refactor/28-orchestrator-handoff.md
Normal file
235
documentations/refactor/28-orchestrator-handoff.md
Normal 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.
|
||||
@@ -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.
|
||||
128
documentations/refactor/README.md
Normal file
128
documentations/refactor/README.md
Normal 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
33
frontend/README.md
Normal 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
12
frontend/index.html
Normal 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
4448
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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
83
frontend/src/api.ts
Normal 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 }),
|
||||
})
|
||||
}
|
||||
65
frontend/src/components/CliCommands.tsx
Normal file
65
frontend/src/components/CliCommands.tsx
Normal 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}` }]
|
||||
}
|
||||
88
frontend/src/components/DatasetStatusView.tsx
Normal file
88
frontend/src/components/DatasetStatusView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/DatasetStrip.tsx
Normal file
35
frontend/src/components/DatasetStrip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/DriverCell.tsx
Normal file
17
frontend/src/components/DriverCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/components/LapsView.tsx
Normal file
119
frontend/src/components/LapsView.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
268
frontend/src/components/LocalDataNavigator.tsx
Normal file
268
frontend/src/components/LocalDataNavigator.tsx
Normal 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 <key></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'
|
||||
108
frontend/src/components/MeetingDetailPanel.tsx
Normal file
108
frontend/src/components/MeetingDetailPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/Nav.tsx
Normal file
30
frontend/src/components/Nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
frontend/src/components/OverviewView.tsx
Normal file
281
frontend/src/components/OverviewView.tsx
Normal 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 : '') || '',
|
||||
}
|
||||
}
|
||||
73
frontend/src/components/PaddockBriefing.tsx
Normal file
73
frontend/src/components/PaddockBriefing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
frontend/src/components/RaceControlView.tsx
Normal file
91
frontend/src/components/RaceControlView.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/components/RaceHubHeader.tsx
Normal file
38
frontend/src/components/RaceHubHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
377
frontend/src/components/RaceStoryCanvas.tsx
Normal file
377
frontend/src/components/RaceStoryCanvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/SessionCoverageDots.tsx
Normal file
17
frontend/src/components/SessionCoverageDots.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/SourceBadge.tsx
Normal file
28
frontend/src/components/SourceBadge.tsx
Normal 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'
|
||||
}
|
||||
}
|
||||
235
frontend/src/components/StrategyView.tsx
Normal file
235
frontend/src/components/StrategyView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/TabBar.tsx
Normal file
41
frontend/src/components/TabBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/WeatherView.tsx
Normal file
98
frontend/src/components/WeatherView.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/WeekendSwitcher.tsx
Normal file
140
frontend/src/components/WeekendSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/components/live/RaceControlFeed.tsx
Normal file
38
frontend/src/components/live/RaceControlFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
frontend/src/components/live/SessionBanner.tsx
Normal file
50
frontend/src/components/live/SessionBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
frontend/src/components/live/TimingTower.tsx
Normal file
96
frontend/src/components/live/TimingTower.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
frontend/src/lib/coverage.ts
Normal file
79
frontend/src/lib/coverage.ts
Normal 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'
|
||||
}
|
||||
110
frontend/src/lib/gpIdentity.ts
Normal file
110
frontend/src/lib/gpIdentity.ts
Normal 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
162
frontend/src/lib/live.ts
Normal 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(':')
|
||||
}
|
||||
168
frontend/src/lib/schedule.ts
Normal file
168
frontend/src/lib/schedule.ts
Normal 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
23
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
488
frontend/src/pages/BriefingPage.tsx
Normal file
488
frontend/src/pages/BriefingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
428
frontend/src/pages/CommandCenterPage.tsx
Normal file
428
frontend/src/pages/CommandCenterPage.tsx
Normal 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
|
||||
}
|
||||
332
frontend/src/pages/DataLibraryPage.tsx
Normal file
332
frontend/src/pages/DataLibraryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/pages/LiveTimingPage.tsx
Normal file
119
frontend/src/pages/LiveTimingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
356
frontend/src/pages/RaceHubPage.tsx
Normal file
356
frontend/src/pages/RaceHubPage.tsx
Normal 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
81
frontend/src/router.tsx
Normal 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
3202
frontend/src/styles/app.css
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/src/test/CliCommands.test.tsx
Normal file
39
frontend/src/test/CliCommands.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
137
frontend/src/test/CommandCenterPage.test.tsx
Normal file
137
frontend/src/test/CommandCenterPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
144
frontend/src/test/DataLibraryPage.test.tsx
Normal file
144
frontend/src/test/DataLibraryPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
90
frontend/src/test/DatasetStatusView.test.tsx
Normal file
90
frontend/src/test/DatasetStatusView.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
83
frontend/src/test/LapsView.test.tsx
Normal file
83
frontend/src/test/LapsView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
151
frontend/src/test/LocalDataNavigator.test.tsx
Normal file
151
frontend/src/test/LocalDataNavigator.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
50
frontend/src/test/RaceControlView.test.tsx
Normal file
50
frontend/src/test/RaceControlView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
258
frontend/src/test/RaceHubPage.test.tsx
Normal file
258
frontend/src/test/RaceHubPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
134
frontend/src/test/StrategyView.test.tsx
Normal file
134
frontend/src/test/StrategyView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
37
frontend/src/test/TabBar.test.tsx
Normal file
37
frontend/src/test/TabBar.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
49
frontend/src/test/WeatherView.test.tsx
Normal file
49
frontend/src/test/WeatherView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
69
frontend/src/test/coverage.test.ts
Normal file
69
frontend/src/test/coverage.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
139
frontend/src/test/live.test.ts
Normal file
139
frontend/src/test/live.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
81
frontend/src/test/schedule.test.ts
Normal file
81
frontend/src/test/schedule.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
6
frontend/src/test/setup.ts
Normal file
6
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
value: () => {},
|
||||
writable: true,
|
||||
})
|
||||
115
frontend/src/test/utils.test.ts
Normal file
115
frontend/src/test/utils.test.ts
Normal 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
290
frontend/src/types.ts
Normal 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
96
frontend/src/utils.ts
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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
10
go.mod
@@ -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
115
go.sum
@@ -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=
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
60
internal/api/session_selection_test.go
Normal file
60
internal/api/session_selection_test.go
Normal 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
982
internal/ingest/ingest.go
Normal 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
Reference in New Issue
Block a user