mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Compare commits
3 Commits
85a8b6ad44
...
b7696c11de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7696c11de | ||
|
|
8e3d4cea3f | ||
|
|
c973c03689 |
230
README.md
230
README.md
@@ -1,228 +1,66 @@
|
||||
# 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:
|
||||
**box-box** is an unofficial F1 race-weekend command center: live timing, Race Hub analytics, championship context, paddock briefing feeds, and local historical data in one Go + React app, with a preserved Bubble Tea TUI.
|
||||
|
||||
- **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.
|
||||
Live demo: [box-box.amantahiliani.com](https://box-box.amantahiliani.com/)
|
||||
|
||||
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).
|
||||
## What It Does
|
||||
|
||||
## How it fits together
|
||||
- **Command Center**: current race-weekend home with GP identity, live status, schedule, championship leaders, and direct analysis links.
|
||||
- **Race Hub**: session workspace for overview, race story, strategy, laps, weather, race control, and dataset coverage.
|
||||
- **Live Timing**: official F1 SignalR feed bridged through the Go server to the browser via SSE.
|
||||
- **Championship View**: standings, form, teammate context, cumulative points, and a simulator.
|
||||
- **Paddock Briefing**: RSS/Atom news ingestion for a local race-weekend briefing surface.
|
||||
- **Local-first history**: OpenF1 data ingested into a SQLite domain database for fast historical browsing.
|
||||
- **Terminal Mode**: Bubble Tea TUI with standings, calendar, driver profiles, live timing, track map, battles, pit window, and replay.
|
||||
|
||||
| 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. |
|
||||
|
||||
The Web UI should call **local-first Go APIs** (`/api/v1/...`). Do not add direct OpenF1 reads in the frontend.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [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
|
||||
|
||||
## Install
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AmanTahiliani/box-box.git
|
||||
cd box-box
|
||||
|
||||
npm install # Playwright and repo-level test scripts
|
||||
npm install --prefix frontend # Vite + React app
|
||||
```
|
||||
npm install
|
||||
npm install --prefix frontend
|
||||
|
||||
## 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`–`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` | Quit |
|
||||
|
||||
## Run — Web (Go serves API + built React)
|
||||
|
||||
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
|
||||
npm run build --prefix frontend
|
||||
go run ./cmd/main.go --web
|
||||
# → http://localhost:8080
|
||||
# http://localhost:8080
|
||||
```
|
||||
|
||||
Use a specific domain database or port:
|
||||
For a local frontend development loop with seeded data, see [docs/getting-started.md](docs/getting-started.md).
|
||||
|
||||
```bash
|
||||
go run ./cmd/main.go --web --db ~/.local/share/box-box/boxbox.db --port 8080
|
||||
```
|
||||
## Project Shape
|
||||
|
||||
## 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 |
|
||||
| Area | What lives there |
|
||||
| --- | --- |
|
||||
| `/` | **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 |
|
||||
| `cmd/main.go` | Entry point for TUI, web server, and ingestion CLI |
|
||||
| `internal/web/` | Go REST API, SSE live bridge, SPA serving |
|
||||
| `internal/live/` | Official F1 SignalR client shared by Web and TUI |
|
||||
| `internal/store/`, `internal/ingest/`, `internal/query/` | Local SQLite domain database, ingestion, and read models |
|
||||
| `internal/ui/` | Bubble Tea TUI |
|
||||
| `frontend/` | React + Vite + TypeScript web app |
|
||||
| `tests/` | Playwright e2e and visual coverage |
|
||||
|
||||
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
|
||||
The Web UI is local-first and should call the Go APIs under `/api/v1/...`; it should not read OpenF1 directly.
|
||||
|
||||
## Ingest historical data and briefing feeds
|
||||
## Documentation
|
||||
|
||||
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.
|
||||
- [Getting Started](docs/getting-started.md): install, build, run modes, TUI keybindings, and web routes.
|
||||
- [Data and Operations](docs/data-and-operations.md): ingestion, environment variables, local files, and live timing notes.
|
||||
- [Testing](docs/testing.md): Go, frontend, e2e, and visual regression commands.
|
||||
- [Architecture Notes](documentations/refactor/README.md): deeper design rationale, data-source decisions, and phase history.
|
||||
|
||||
```bash
|
||||
# Season: discover and store meeting metadata (2023+)
|
||||
go run ./cmd/main.go --ingest-year 2025
|
||||
## Status
|
||||
|
||||
# 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.
|
||||
Pre-beta and actively developed. The Web UI is the primary surface; the TUI is preserved and still useful for terminal workflows. Live timing depends on F1 broadcasting timing data, so it is only fully active during live sessions.
|
||||
|
||||
## License
|
||||
|
||||
MIT © [Aman Tahiliani](https://github.com/AmanTahiliani)
|
||||
|
||||
---
|
||||
|
||||
*Unofficial project; not associated with Formula 1 or the FIA.*
|
||||
|
||||
BIN
docs/assets/command-center.jpg
Normal file
BIN
docs/assets/command-center.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
75
docs/data-and-operations.md
Normal file
75
docs/data-and-operations.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Data and Operations
|
||||
|
||||
## Data Flow
|
||||
|
||||
| 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 used by the TUI and legacy paths. Separate from the domain DB. |
|
||||
| Official F1 SignalR | Live timing bridge in `internal/live`, exposed to Web via SSE and to the 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. |
|
||||
|
||||
The Web UI should call local-first Go APIs under `/api/v1/...`; do not add direct OpenF1 reads in the frontend.
|
||||
|
||||
## 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 for 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
|
||||
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
|
||||
```
|
||||
|
||||
Use `--ingest-meeting` for a complete weekend. `--ingest-year` stores season meetings, not full session datasets. Optional analytics fetches may partially fail without aborting the whole run.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `OPENF1_API_KEY` | Optional Bearer token for paid OpenF1 behavior. |
|
||||
| `BOXBOX_DISABLE_LIVE=1` | Skip the background SignalR live feed in web mode. Used by CI and local seeded UI work. |
|
||||
| `BOXBOX_OPENF1_BASE_URL` | Override the OpenF1 API root. Defaults to `https://api.openf1.org`. |
|
||||
| `BOXBOX_API_PORT` | Go API port used by the Vite dev proxy. Defaults to `8080`. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export OPENF1_API_KEY=your_key_here
|
||||
go run ./cmd/main.go --web
|
||||
```
|
||||
|
||||
## Local Files
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.local/share/box-box/boxbox.db` | Domain database, default `--db`. |
|
||||
| `~/.cache/box-box/cache.db` | OpenF1 HTTP response cache for the TUI and client. |
|
||||
| `box-box.log` | TUI application log in the project root. |
|
||||
| `frontend/dist/` | Production React build. Generated output, do not commit. |
|
||||
| `.playwright/*.db` | Seeded databases for automated tests. |
|
||||
|
||||
Web mode logs to stderr.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Live timing only works when F1 is broadcasting timing data; there is no guaranteed live session for local development.
|
||||
- E2E and visual tests use `BOXBOX_DISABLE_LIVE=1` and seeded SQLite, so 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.
|
||||
94
docs/getting-started.md
Normal file
94
docs/getting-started.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Getting Started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Go](https://go.dev/doc/install), using the module version in `go.mod`.
|
||||
- [Node.js](https://nodejs.org/) 18+ and npm.
|
||||
- Internet access for ingestion and TUI OpenF1 calls.
|
||||
- For e2e and visual tests: `npx playwright install` after `npm install` at the repo root.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AmanTahiliani/box-box.git
|
||||
cd box-box
|
||||
|
||||
npm install # Playwright and repo-level test scripts
|
||||
npm install --prefix frontend # Vite + React app
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -o box-box ./cmd/main.go
|
||||
npm run build --prefix frontend # writes frontend/dist
|
||||
```
|
||||
|
||||
## Run: Web
|
||||
|
||||
Build the frontend first, then start web mode. Go walks up from the current directory to find `frontend/dist/index.html`; if missing, it serves embedded legacy assets.
|
||||
|
||||
```bash
|
||||
npm run build --prefix frontend
|
||||
go run ./cmd/main.go --web
|
||||
# http://localhost:8080
|
||||
```
|
||||
|
||||
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 proxies `/api` to the Go server. Set `BOXBOX_API_PORT` to match the Go `--port`.
|
||||
|
||||
Terminal 1: API with a seeded database:
|
||||
|
||||
```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
|
||||
# http://localhost:5173
|
||||
```
|
||||
|
||||
`BOXBOX_DISABLE_LIVE=1` skips starting the SignalR bridge, which is useful for CI and local UI work.
|
||||
|
||||
## Run: TUI
|
||||
|
||||
```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`-`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 in Race Detail race sessions |
|
||||
| `y` | Cycle season year |
|
||||
| `q` / `ctrl+c` | Quit |
|
||||
|
||||
## Web Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` | Command Center: race-weekend home with GP identity, live status, schedule, and analysis links |
|
||||
| `/race-hub?session_key=<key>` | Race Hub: session workspace with Overview, Race Story, Strategy, Lap Data, Conditions, Race Control, and Data Status tabs |
|
||||
| `/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`.
|
||||
59
docs/testing.md
Normal file
59
docs/testing.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Testing
|
||||
|
||||
## Go
|
||||
|
||||
Targeted offline-ish packages:
|
||||
|
||||
```bash
|
||||
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
|
||||
```
|
||||
|
||||
All packages:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
OpenF1 integration tests require network access and are rate-limit aware:
|
||||
|
||||
```bash
|
||||
go test -v ./internal/api
|
||||
```
|
||||
|
||||
## Frontend Unit Tests and Build
|
||||
|
||||
```bash
|
||||
npm --prefix frontend test -- --run
|
||||
npm --prefix frontend run build
|
||||
```
|
||||
|
||||
## E2E
|
||||
|
||||
The default Playwright config starts a seeded Go server on port `18080` and Vite on `15173`.
|
||||
|
||||
```bash
|
||||
npx playwright install # first time only
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Production serving mode builds around Go serving `frontend/dist`:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:prod
|
||||
```
|
||||
|
||||
## Visual Regression
|
||||
|
||||
```bash
|
||||
npm run test:visual
|
||||
npm run test:visual:prod
|
||||
```
|
||||
|
||||
After intentional UI changes:
|
||||
|
||||
```bash
|
||||
npm run test:visual:update
|
||||
npm run test:visual:prod:update
|
||||
```
|
||||
|
||||
Snapshots live under `tests/visual/__snapshots__/`.
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
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>
|
||||
if (source === 'cancelled') return <span className="badge badge-cancelled">Cancelled</span>
|
||||
return <span className="badge badge-none">No data</span>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type Source = 'local' | 'partial' | 'none'
|
||||
type Source = 'local' | 'partial' | 'none' | 'cancelled'
|
||||
|
||||
interface Props {
|
||||
source: Source
|
||||
@@ -11,6 +11,8 @@ export function SourceBadge({ source, label }: Props) {
|
||||
return <span className="badge badge-local">{label ?? 'Local'}</span>
|
||||
case 'partial':
|
||||
return <span className="badge badge-partial">{label ?? 'Partial'}</span>
|
||||
case 'cancelled':
|
||||
return <span className="badge badge-cancelled">{label ?? 'Cancelled'}</span>
|
||||
default:
|
||||
return <span className="badge badge-none">{label ?? 'None'}</span>
|
||||
}
|
||||
@@ -22,6 +24,8 @@ export function weekendStatusLabel(source: Source): string {
|
||||
return 'Full'
|
||||
case 'partial':
|
||||
return 'Partial'
|
||||
case 'cancelled':
|
||||
return 'Cancelled'
|
||||
default:
|
||||
return 'Missing'
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export function countWeekendStats(weekends: (Weekend | undefined)[]) {
|
||||
let full = 0
|
||||
let partial = 0
|
||||
let missing = 0
|
||||
let cancelled = 0
|
||||
|
||||
for (const weekend of weekends) {
|
||||
if (!weekend || weekend.sessions.length === 0) {
|
||||
@@ -64,15 +65,19 @@ export function countWeekendStats(weekends: (Weekend | undefined)[]) {
|
||||
case 'partial':
|
||||
partial++
|
||||
break
|
||||
case 'cancelled':
|
||||
cancelled++
|
||||
break
|
||||
default:
|
||||
missing++
|
||||
}
|
||||
}
|
||||
|
||||
return { full, partial, missing, total: weekends.length }
|
||||
return { full, partial, cancelled, missing, total: weekends.length }
|
||||
}
|
||||
|
||||
export function sessionIconClass(session: WeekendSession): string {
|
||||
if (session.source === 'cancelled') return 'si-cancelled'
|
||||
if (session.source === 'none') return 'si-missing'
|
||||
if (isSessionComplete(session.datasets)) return 'si-full'
|
||||
return 'si-partial'
|
||||
|
||||
@@ -137,6 +137,9 @@ export function DataLibraryPage() {
|
||||
<span>
|
||||
<em className="dl-stat-partial">{stats.partial}</em> partial
|
||||
</span>
|
||||
<span>
|
||||
<em className="dl-stat-cancelled">{stats.cancelled}</em> cancelled
|
||||
</span>
|
||||
<span>
|
||||
<em>{stats.missing}</em> missing
|
||||
</span>
|
||||
@@ -177,6 +180,10 @@ export function DataLibraryPage() {
|
||||
<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">Cancelled</span>
|
||||
<span className="dl-stat-val dl-stat-cancelled">{stats.cancelled}</span>
|
||||
</div>
|
||||
<div className="dl-stat">
|
||||
<span className="dl-stat-label">Missing</span>
|
||||
<span className="dl-stat-val">{stats.missing}</span>
|
||||
@@ -294,7 +301,9 @@ export function DataLibraryPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
|
||||
{weekend && weekend.sessions.length > 0
|
||||
{weekend?.source === 'cancelled'
|
||||
? 'cancelled'
|
||||
: weekend && weekend.sessions.length > 0
|
||||
? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full`
|
||||
: '—'}
|
||||
</td>
|
||||
|
||||
@@ -361,6 +361,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.badge-local { background: rgba(57,199,58,.12); color: var(--green); border: 1px solid rgba(57,199,58,.25); }
|
||||
.badge-partial { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); }
|
||||
.badge-cancelled { background: rgba(255,107,53,.12); color: #ff8a5c; border: 1px solid rgba(255,107,53,.28); }
|
||||
.badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
|
||||
|
||||
/* ── Dataset strip ── */
|
||||
@@ -1178,6 +1179,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.dl-banner-stats em.dl-stat-full { color: var(--green); }
|
||||
.dl-banner-stats em.dl-stat-partial { color: var(--yellow); }
|
||||
.dl-banner-stats em.dl-stat-cancelled { color: #ff8a5c; }
|
||||
|
||||
.dl-footer-link {
|
||||
display: flex;
|
||||
@@ -1259,6 +1261,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.dl-stat-full { color: var(--green); }
|
||||
.dl-stat-partial { color: var(--yellow); }
|
||||
.dl-stat-cancelled { color: #ff8a5c; }
|
||||
|
||||
.dl-content {
|
||||
display: flex;
|
||||
@@ -1328,6 +1331,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.session-icon.si-full { background: rgba(57,199,58,0.2); color: var(--green); }
|
||||
.session-icon.si-partial { background: rgba(255,214,0,0.2); color: var(--yellow); }
|
||||
.session-icon.si-cancelled { background: rgba(255,107,53,0.18); color: #ff8a5c; }
|
||||
.session-icon.si-missing { background: rgba(50,50,50,0.5); color: var(--text-3); }
|
||||
|
||||
.dl-detail-wrap {
|
||||
@@ -4440,4 +4444,3 @@ a { color: inherit; text-decoration: none; }
|
||||
background: #f82f34;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,18 @@ describe('coverage helpers', () => {
|
||||
meeting: {} as Weekend['meeting'],
|
||||
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'partial', datasets: {} }],
|
||||
}
|
||||
expect(countWeekendStats([local, partial, undefined])).toEqual({
|
||||
const cancelled: Weekend = {
|
||||
source: 'cancelled',
|
||||
meeting_key: 3,
|
||||
meeting: {} as Weekend['meeting'],
|
||||
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'cancelled', datasets: {} }],
|
||||
}
|
||||
expect(countWeekendStats([local, partial, cancelled, undefined])).toEqual({
|
||||
full: 1,
|
||||
partial: 1,
|
||||
cancelled: 1,
|
||||
missing: 1,
|
||||
total: 3,
|
||||
total: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Meeting {
|
||||
date_start: string
|
||||
date_end: string
|
||||
year: number
|
||||
is_cancelled?: boolean
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
@@ -26,6 +27,7 @@ export interface Session {
|
||||
date_start: string
|
||||
date_end: string
|
||||
gmt_offset: string
|
||||
is_cancelled?: boolean
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
@@ -73,7 +75,7 @@ export interface EnrichedGrid {
|
||||
}
|
||||
|
||||
export interface RaceHub {
|
||||
source: 'local' | 'partial' | 'none'
|
||||
source: 'local' | 'partial' | 'none' | 'cancelled'
|
||||
session_key: number
|
||||
datasets: Record<string, DatasetInfo>
|
||||
meeting?: Meeting
|
||||
@@ -158,12 +160,12 @@ export interface Lap {
|
||||
|
||||
export interface WeekendSession {
|
||||
session: Session
|
||||
source: 'local' | 'partial' | 'none'
|
||||
source: 'local' | 'partial' | 'none' | 'cancelled'
|
||||
datasets: Record<string, DatasetInfo>
|
||||
}
|
||||
|
||||
export interface Weekend {
|
||||
source: 'local' | 'partial' | 'none'
|
||||
source: 'local' | 'partial' | 'none' | 'cancelled'
|
||||
meeting_key: number
|
||||
meeting: Meeting
|
||||
sessions: WeekendSession[]
|
||||
|
||||
@@ -430,6 +430,27 @@ func (s *Service) ingestSessionDatasets(sess models.Session) (Summary, error) {
|
||||
coverage = make(map[string]store.CoverageEntry)
|
||||
}
|
||||
|
||||
if sess.IsCancelled {
|
||||
s.opts.Progress.Step("session %d (%s) is cancelled; skipping Race Hub datasets", sessionKey, sess.SessionName)
|
||||
if !s.opts.DryRun {
|
||||
for _, dataset := range []string{
|
||||
"drivers",
|
||||
"session_result",
|
||||
"starting_grid",
|
||||
"stints",
|
||||
"pit_stops",
|
||||
"positions",
|
||||
"race_control",
|
||||
"weather",
|
||||
"laps",
|
||||
} {
|
||||
_ = s.store.UpsertCoverage(sessionKey, dataset, "skipped", 0, "session cancelled")
|
||||
}
|
||||
}
|
||||
summary.Status = statusForCancelled(s.opts.DryRun)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -919,6 +940,13 @@ func statusForErrors(dryRun bool, errs []string) string {
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func statusForCancelled(dryRun bool) string {
|
||||
if dryRun {
|
||||
return "dry_run"
|
||||
}
|
||||
return "cancelled"
|
||||
}
|
||||
|
||||
type fetchFunc[T any] func() (FetchResult, T, error)
|
||||
|
||||
func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) {
|
||||
|
||||
@@ -325,6 +325,7 @@ func meetingToStore(m models.Meeting) store.Meeting {
|
||||
DateStart: m.DateStart,
|
||||
DateEnd: m.DateEnd,
|
||||
Year: m.Year,
|
||||
IsCancelled: m.IsCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +339,7 @@ func sessionToStore(s models.Session) store.Session {
|
||||
DateStart: s.DateStart,
|
||||
DateEnd: s.DateEnd,
|
||||
GMTOffset: s.GMTOffset,
|
||||
IsCancelled: s.IsCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type Meeting struct {
|
||||
DateStart string `json:"date_start"`
|
||||
DateEnd string `json:"date_end"`
|
||||
Year int `json:"year"`
|
||||
|
||||
IsCancelled bool `json:"is_cancelled"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
@@ -38,6 +40,8 @@ type Session struct {
|
||||
DateStart string `json:"date_start"`
|
||||
DateEnd string `json:"date_end"`
|
||||
GMTOffset string `json:"gmt_offset"`
|
||||
|
||||
IsCancelled bool `json:"is_cancelled"`
|
||||
}
|
||||
|
||||
// TyreCompound represents the type of tyre compound used.
|
||||
@@ -169,7 +173,7 @@ type Pit struct {
|
||||
LaneDuration float64 `json:"lane_duration"` // pit lane time (entry to exit)
|
||||
LapNumber int `json:"lap_number"`
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
PitDuration float64 `json:"pit_duration"` // deprecated, use StopDuration
|
||||
PitDuration float64 `json:"pit_duration"` // deprecated, use StopDuration
|
||||
SessionKey int `json:"session_key"`
|
||||
StopDuration float64 `json:"stop_duration"` // stationary time only
|
||||
}
|
||||
@@ -186,7 +190,7 @@ type Interval struct {
|
||||
Date string `json:"date"`
|
||||
DriverNumber int `json:"driver_number"`
|
||||
GapToLeader *float64 `json:"gap_to_leader"` // null when leading
|
||||
Interval *float64 `json:"interval"` // null when leading
|
||||
Interval *float64 `json:"interval"` // null when leading
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
SessionKey int `json:"session_key"`
|
||||
}
|
||||
@@ -228,7 +232,7 @@ type Weather struct {
|
||||
}
|
||||
|
||||
type CarData struct {
|
||||
Brake int `json:"brake"` // 0-100
|
||||
Brake int `json:"brake"` // 0-100
|
||||
Date string `json:"date"`
|
||||
DriverNumber int `json:"driver_number"`
|
||||
DRS int `json:"drs"` // 0=off, 8=eligible, 10=open
|
||||
|
||||
@@ -23,6 +23,8 @@ func meetingToModel(m store.Meeting) models.Meeting {
|
||||
DateStart: m.DateStart,
|
||||
DateEnd: m.DateEnd,
|
||||
Year: m.Year,
|
||||
|
||||
IsCancelled: m.IsCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +38,8 @@ func sessionToModel(s store.Session) models.Session {
|
||||
DateStart: s.DateStart,
|
||||
DateEnd: s.DateEnd,
|
||||
GMTOffset: s.GMTOffset,
|
||||
|
||||
IsCancelled: s.IsCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,3 +55,23 @@ func datasetsFromCounts(meetingAvailable, sessionAvailable bool, counts store.Se
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
func cancelledDatasets() map[string]DatasetInfo {
|
||||
ds := emptyDatasetMap()
|
||||
ds["meeting"] = availableLocal(1)
|
||||
ds["session"] = availableLocal(1)
|
||||
for _, key := range []string{
|
||||
"drivers",
|
||||
"results",
|
||||
"starting_grid",
|
||||
"stints",
|
||||
"pit_stops",
|
||||
"positions",
|
||||
"race_control",
|
||||
"weather",
|
||||
"laps",
|
||||
} {
|
||||
ds[key] = skippedNA()
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ const (
|
||||
DataSourceNone = "none"
|
||||
DataSourceOpenF1 = "openf1"
|
||||
|
||||
ResponseSourceLocal = "local"
|
||||
ResponseSourceNone = "none"
|
||||
ResponseSourcePartial = "partial"
|
||||
ResponseSourceLocal = "local"
|
||||
ResponseSourceNone = "none"
|
||||
ResponseSourcePartial = "partial"
|
||||
ResponseSourceCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// DatasetInfo describes availability of a single dataset.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
// ErrMeetingNotFound is returned when a meeting is not in the local store.
|
||||
@@ -65,21 +66,35 @@ func (s *Service) GetWeekend(meetingKey int) (Weekend, error) {
|
||||
}
|
||||
datasets := datasetsFromCounts(true, true, counts)
|
||||
sessionModel := sessionToModel(sess)
|
||||
if sess.IsCancelled {
|
||||
datasets = cancelledDatasets()
|
||||
}
|
||||
if datasets["starting_grid"].Status == DatasetStatusMissing && !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) {
|
||||
datasets["starting_grid"] = skippedNA()
|
||||
}
|
||||
out.Sessions = append(out.Sessions, WeekendSession{
|
||||
Session: sessionModel,
|
||||
Source: responseSource(datasets),
|
||||
Source: sessionSource(sess, datasets),
|
||||
Datasets: datasets,
|
||||
})
|
||||
}
|
||||
|
||||
out.Source = weekendSource(out.Sessions)
|
||||
if meeting.IsCancelled {
|
||||
out.Source = ResponseSourceCancelled
|
||||
} else {
|
||||
out.Source = weekendSource(out.Sessions)
|
||||
}
|
||||
out.DefaultSessionKey = pickDefaultSession(out.Sessions)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sessionSource(sess store.Session, datasets map[string]DatasetInfo) string {
|
||||
if sess.IsCancelled {
|
||||
return ResponseSourceCancelled
|
||||
}
|
||||
return responseSource(datasets)
|
||||
}
|
||||
|
||||
func weekendSource(sessions []WeekendSession) string {
|
||||
if len(sessions) == 0 {
|
||||
return ResponseSourceNone
|
||||
|
||||
@@ -106,6 +106,12 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub.Datasets["meeting"] = availableLocal(1)
|
||||
}
|
||||
|
||||
if sess.IsCancelled || (hub.Meeting != nil && hub.Meeting.IsCancelled) {
|
||||
hub.Source = ResponseSourceCancelled
|
||||
hub.Datasets = cancelledDatasets()
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
driverLinks, err := s.store.ListSessionDrivers(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestCoverageCRUD(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion() error = %v", err)
|
||||
}
|
||||
if version != 6 {
|
||||
t.Fatalf("SchemaVersion() = %d, want 6", version)
|
||||
if version != 7 {
|
||||
t.Fatalf("SchemaVersion() = %d, want 7", version)
|
||||
}
|
||||
|
||||
// Verify session_coverage table exists
|
||||
|
||||
@@ -16,8 +16,8 @@ func (s *Store) UpsertMeeting(m Meeting) error {
|
||||
INSERT INTO meetings (
|
||||
meeting_key, meeting_name, meeting_official_name, location,
|
||||
country_code, country_name, circuit_key, circuit_short_name,
|
||||
gmt_offset, date_start, date_end, year, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
gmt_offset, date_start, date_end, year, is_cancelled, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(meeting_key) DO UPDATE SET
|
||||
meeting_name = excluded.meeting_name,
|
||||
meeting_official_name = excluded.meeting_official_name,
|
||||
@@ -30,6 +30,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
|
||||
date_start = excluded.date_start,
|
||||
date_end = excluded.date_end,
|
||||
year = excluded.year,
|
||||
is_cancelled = excluded.is_cancelled,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
m.MeetingKey,
|
||||
@@ -44,6 +45,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
|
||||
nullString(m.DateStart),
|
||||
nullString(m.DateEnd),
|
||||
m.Year,
|
||||
boolInt(m.IsCancelled),
|
||||
m.UpdatedAt.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -56,6 +58,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
|
||||
func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
|
||||
var m Meeting
|
||||
var updatedAt int64
|
||||
var isCancelled int
|
||||
var officialName, location, countryCode, countryName sql.NullString
|
||||
var circuitKey sql.NullInt64
|
||||
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
|
||||
@@ -63,7 +66,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
|
||||
err := s.db.QueryRow(`
|
||||
SELECT meeting_key, meeting_name, meeting_official_name, location,
|
||||
country_code, country_name, circuit_key, circuit_short_name,
|
||||
gmt_offset, date_start, date_end, year, updated_at
|
||||
gmt_offset, date_start, date_end, year, is_cancelled, updated_at
|
||||
FROM meetings
|
||||
WHERE meeting_key = ?
|
||||
`, meetingKey).Scan(
|
||||
@@ -79,6 +82,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
|
||||
&dateStart,
|
||||
&dateEnd,
|
||||
&m.Year,
|
||||
&isCancelled,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -96,6 +100,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
|
||||
m.GMTOffset = gmtOffset.String
|
||||
m.DateStart = dateStart.String
|
||||
m.DateEnd = dateEnd.String
|
||||
m.IsCancelled = isCancelled != 0
|
||||
m.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
return m, nil
|
||||
}
|
||||
@@ -129,7 +134,7 @@ func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT meeting_key, meeting_name, meeting_official_name, location,
|
||||
country_code, country_name, circuit_key, circuit_short_name,
|
||||
gmt_offset, date_start, date_end, year, updated_at
|
||||
gmt_offset, date_start, date_end, year, is_cancelled, updated_at
|
||||
FROM meetings
|
||||
WHERE year = ?
|
||||
ORDER BY date_start ASC, meeting_key ASC
|
||||
@@ -151,8 +156,8 @@ func (s *Store) UpsertSession(sess Session) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO sessions (
|
||||
session_key, meeting_key, session_name, session_type,
|
||||
circuit_key, date_start, date_end, gmt_offset, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
circuit_key, date_start, date_end, gmt_offset, is_cancelled, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
session_name = excluded.session_name,
|
||||
@@ -161,6 +166,7 @@ func (s *Store) UpsertSession(sess Session) error {
|
||||
date_start = excluded.date_start,
|
||||
date_end = excluded.date_end,
|
||||
gmt_offset = excluded.gmt_offset,
|
||||
is_cancelled = excluded.is_cancelled,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
sess.SessionKey,
|
||||
@@ -171,6 +177,7 @@ func (s *Store) UpsertSession(sess Session) error {
|
||||
nullString(sess.DateStart),
|
||||
nullString(sess.DateEnd),
|
||||
nullString(sess.GMTOffset),
|
||||
boolInt(sess.IsCancelled),
|
||||
sess.UpdatedAt.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -183,12 +190,13 @@ func (s *Store) UpsertSession(sess Session) error {
|
||||
func (s *Store) GetSession(sessionKey int) (Session, error) {
|
||||
var sess Session
|
||||
var updatedAt int64
|
||||
var isCancelled int
|
||||
var circuitKey sql.NullInt64
|
||||
var dateStart, dateEnd, gmtOffset sql.NullString
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT session_key, meeting_key, session_name, session_type,
|
||||
circuit_key, date_start, date_end, gmt_offset, updated_at
|
||||
circuit_key, date_start, date_end, gmt_offset, is_cancelled, updated_at
|
||||
FROM sessions
|
||||
WHERE session_key = ?
|
||||
`, sessionKey).Scan(
|
||||
@@ -200,6 +208,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
|
||||
&dateStart,
|
||||
&dateEnd,
|
||||
&gmtOffset,
|
||||
&isCancelled,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -212,6 +221,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
|
||||
sess.DateStart = dateStart.String
|
||||
sess.DateEnd = dateEnd.String
|
||||
sess.GMTOffset = gmtOffset.String
|
||||
sess.IsCancelled = isCancelled != 0
|
||||
sess.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
return sess, nil
|
||||
}
|
||||
@@ -220,7 +230,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
|
||||
func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, meeting_key, session_name, session_type,
|
||||
circuit_key, date_start, date_end, gmt_offset, updated_at
|
||||
circuit_key, date_start, date_end, gmt_offset, is_cancelled, updated_at
|
||||
FROM sessions
|
||||
WHERE meeting_key = ?
|
||||
ORDER BY date_start ASC, session_key ASC
|
||||
@@ -238,6 +248,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
|
||||
for rows.Next() {
|
||||
var m Meeting
|
||||
var updatedAt int64
|
||||
var isCancelled int
|
||||
var officialName, location, countryCode, countryName sql.NullString
|
||||
var circuitKey sql.NullInt64
|
||||
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
|
||||
@@ -255,6 +266,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
|
||||
&dateStart,
|
||||
&dateEnd,
|
||||
&m.Year,
|
||||
&isCancelled,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -271,6 +283,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
|
||||
m.GMTOffset = gmtOffset.String
|
||||
m.DateStart = dateStart.String
|
||||
m.DateEnd = dateEnd.String
|
||||
m.IsCancelled = isCancelled != 0
|
||||
m.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
out = append(out, m)
|
||||
}
|
||||
@@ -282,6 +295,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
var updatedAt int64
|
||||
var isCancelled int
|
||||
var circuitKey sql.NullInt64
|
||||
var dateStart, dateEnd, gmtOffset sql.NullString
|
||||
|
||||
@@ -294,6 +308,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
|
||||
&dateStart,
|
||||
&dateEnd,
|
||||
&gmtOffset,
|
||||
&isCancelled,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -305,6 +320,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
|
||||
sess.DateStart = dateStart.String
|
||||
sess.DateEnd = dateEnd.String
|
||||
sess.GMTOffset = gmtOffset.String
|
||||
sess.IsCancelled = isCancelled != 0
|
||||
sess.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
out = append(out, sess)
|
||||
}
|
||||
|
||||
3
internal/store/migrations/007_cancelled_schedule.sql
Normal file
3
internal/store/migrations/007_cancelled_schedule.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE meetings ADD COLUMN is_cancelled INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE sessions ADD COLUMN is_cancelled INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -68,6 +68,7 @@ type Meeting struct {
|
||||
DateStart string
|
||||
DateEnd string
|
||||
Year int
|
||||
IsCancelled bool
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ type Session struct {
|
||||
DateStart string
|
||||
DateEnd string
|
||||
GMTOffset string
|
||||
IsCancelled bool
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion() error = %v", err)
|
||||
}
|
||||
if version != 6 {
|
||||
t.Fatalf("SchemaVersion() = %d, want 6", version)
|
||||
if version != 7 {
|
||||
t.Fatalf("SchemaVersion() = %d, want 7", version)
|
||||
}
|
||||
|
||||
tables := []string{
|
||||
@@ -105,6 +105,18 @@ func TestMigrationsAreIdempotent(t *testing.T) {
|
||||
if count != 1 {
|
||||
t.Fatalf("schema_migrations v5 count = %d, want 1", count)
|
||||
}
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 6`).Scan(&count); err != nil {
|
||||
t.Fatalf("count schema_migrations v6: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("schema_migrations v6 count = %d, want 1", count)
|
||||
}
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 7`).Scan(&count); err != nil {
|
||||
t.Fatalf("count schema_migrations v7: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("schema_migrations v7 count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawPayloadInsertAndRead(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user