Compare commits

..

3 Commits

Author SHA1 Message Date
AmanTahiliani
b7696c11de docs: streamline README and split guides 2026-07-03 03:11:16 -04:00
AmanTahiliani
8e3d4cea3f docs: refresh project homepage 2026-07-03 03:07:34 -04:00
AmanTahiliani
c973c03689 Show cancelled schedule status 2026-07-03 02:57:18 -04:00
25 changed files with 435 additions and 225 deletions

230
README.md
View File

@@ -1,228 +1,66 @@
# 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: **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. Live demo: [box-box.amantahiliani.com](https://box-box.amantahiliani.com/)
- **TUI (preserved)** — Bubble Tea terminal app with standings, calendar, driver profiles, official live timing, track map, battles, pit window, and replay.
Historical Web data is **local-first** in a SQLite domain database (ingested from OpenF1). **Live timing** uses the official F1 SignalR feed via the Go server; the React app reads `/api/v1/live/*`, not OpenF1 directly. ![box-box Command Center](docs/assets/command-center.jpg)
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 | ## Quickstart
| --- | --- |
| **OpenF1 REST** | Backfill and ingestion source; optional paid tier via `OPENF1_API_KEY`. Also powers the TUIs on-demand reads and HTTP cache. |
| **Domain SQLite** (`boxbox.db`) | Local store for meetings, sessions, Race Hub datasets, and navigation APIs used by the Web UI. |
| **HTTP cache SQLite** (`cache.db`) | TTL cache for OpenF1 responses (TUI and legacy paths). Separate from the domain DB. |
| **Official F1 SignalR** | Live timing bridge in `internal/live`, exposed to Web (SSE) and TUI. |
| **Go server** | `cmd/main.go` — TUI, `--web` API + static SPA, or CLI ingestion. |
| **React frontend** | `frontend/` — production build served from `frontend/dist` when present. |
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
```bash ```bash
git clone https://github.com/AmanTahiliani/box-box.git git clone https://github.com/AmanTahiliani/box-box.git
cd box-box cd box-box
npm install # Playwright and repo-level test scripts npm install
npm install --prefix frontend # Vite + React app 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 npm run build --prefix frontend
go run ./cmd/main.go --web 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 ## Project Shape
go run ./cmd/main.go --web --db ~/.local/share/box-box/boxbox.db --port 8080
```
## Run — Web dev (Vite + Go API) | Area | What lives there |
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 | | `cmd/main.go` | Entry point for TUI, web server, and ingestion CLI |
| `/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. | | `internal/web/` | Go REST API, SSE live bridge, SPA serving |
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands | | `internal/live/` | Official F1 SignalR client shared by Web and TUI |
| `/data-library` | Legacy alias for Admin / Data Health | | `internal/store/`, `internal/ingest/`, `internal/query/` | Local SQLite domain database, ingestion, and read models |
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live | | `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 ## Status
# Season: discover and store meeting metadata (2023+)
go run ./cmd/main.go --ingest-year 2025
# Full race weekend: all sessions + Race Hub datasets 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.
go run ./cmd/main.go --ingest-meeting 1229
# Single session only
go run ./cmd/main.go --ingest-session 9472
# Preview without writing
go run ./cmd/main.go --dry-run --ingest-meeting 1229
# Refresh Paddock Briefing RSS/Atom feeds
go run ./cmd/main.go --ingest-news
# Custom DB path (default: ~/.local/share/box-box/boxbox.db)
go run ./cmd/main.go --ingest-meeting 1229 --db /tmp/boxbox.db
```
Use **`--ingest-meeting`** for a complete weekend. **`--ingest-year`** lists meetings for the season; ingest meetings individually or by weekend as needed. Optional analytics fetches may partially fail without aborting the whole run.
## `OPENF1_API_KEY`
```bash
export OPENF1_API_KEY=your_key_here
go run ./cmd/main.go --web
```
Without a key, the free OpenF1 tier is used. A key may be required for paid-tier behavior (e.g. live session access during API lockouts). Ingestion and TUI calls use the same client.
## Local files
| Path | Purpose |
| --- | --- |
| `~/.local/share/box-box/boxbox.db` | Domain database (default `--db`) |
| `~/.cache/box-box/cache.db` | OpenF1 HTTP response cache (TUI / client) |
| `box-box.log` | TUI application log (project root) |
| `frontend/dist/` | Production React build (**gitignored** — build locally, do not commit) |
| `.playwright/*.db` | Seeded DBs for automated tests |
Web mode logs to **stderr**.
## Tests and QA
### Go (targeted packages)
```bash
go test ./internal/live ./internal/models ./internal/store ./internal/ingest ./internal/query ./internal/web
```
All packages: `go test ./...`
OpenF1 integration tests (network, rate-limit aware): `go test -v ./internal/api`
### Frontend unit tests and build
```bash
npm --prefix frontend test -- --run
npm --prefix frontend run build
```
### E2E (Vite dev proxy + seeded API)
Starts seeded Go on port `18080` and Vite on `15173` (see `playwright.config.ts`).
```bash
npx playwright install # first time only
npm run test:e2e
```
### E2E production (Go serves `frontend/dist`)
```bash
npm run test:e2e:prod
```
### Visual regression (screenshots)
```bash
npm run test:visual # dev proxy stack
npm run test:visual:prod # production serving (canonical baselines)
# after intentional UI changes
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/`.
## Known limitations
- **Live timing** only works when F1 is broadcasting timing data; there is no guaranteed live session for local dev.
- **E2E / visual tests** use `BOXBOX_DISABLE_LIVE=1` and seeded SQLite — they do not exercise full SignalR live behavior.
- **`frontend/dist`** is generated output; build before production web mode or `test:e2e:prod`.
- **TUI historical views** still use OpenF1 on demand with the HTTP cache; the Web UIs local-first model does not fully replace the TUI yet.
- **`--ingest-year`** stores season meetings, not full session datasets — use `--ingest-meeting` or `--ingest-session` for Race Hub data.
## License ## License
MIT © [Aman Tahiliani](https://github.com/AmanTahiliani) MIT © [Aman Tahiliani](https://github.com/AmanTahiliani)
---
*Unofficial project; not associated with Formula 1 or the FIA.* *Unofficial project; not associated with Formula 1 or the FIA.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View 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
View 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
View 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__/`.

View File

@@ -10,6 +10,7 @@ interface Props {
function SourceBadge({ source }: { source: RaceHub['source'] }) { function SourceBadge({ source }: { source: RaceHub['source'] }) {
if (source === 'local') return <span className="badge badge-local">Local</span> if (source === 'local') return <span className="badge badge-local">Local</span>
if (source === 'partial') return <span className="badge badge-partial">Partial</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> return <span className="badge badge-none">No data</span>
} }

View File

@@ -1,4 +1,4 @@
type Source = 'local' | 'partial' | 'none' type Source = 'local' | 'partial' | 'none' | 'cancelled'
interface Props { interface Props {
source: Source source: Source
@@ -11,6 +11,8 @@ export function SourceBadge({ source, label }: Props) {
return <span className="badge badge-local">{label ?? 'Local'}</span> return <span className="badge badge-local">{label ?? 'Local'}</span>
case 'partial': case 'partial':
return <span className="badge badge-partial">{label ?? 'Partial'}</span> return <span className="badge badge-partial">{label ?? 'Partial'}</span>
case 'cancelled':
return <span className="badge badge-cancelled">{label ?? 'Cancelled'}</span>
default: default:
return <span className="badge badge-none">{label ?? 'None'}</span> return <span className="badge badge-none">{label ?? 'None'}</span>
} }
@@ -22,6 +24,8 @@ export function weekendStatusLabel(source: Source): string {
return 'Full' return 'Full'
case 'partial': case 'partial':
return 'Partial' return 'Partial'
case 'cancelled':
return 'Cancelled'
default: default:
return 'Missing' return 'Missing'
} }

View File

@@ -51,6 +51,7 @@ export function countWeekendStats(weekends: (Weekend | undefined)[]) {
let full = 0 let full = 0
let partial = 0 let partial = 0
let missing = 0 let missing = 0
let cancelled = 0
for (const weekend of weekends) { for (const weekend of weekends) {
if (!weekend || weekend.sessions.length === 0) { if (!weekend || weekend.sessions.length === 0) {
@@ -64,15 +65,19 @@ export function countWeekendStats(weekends: (Weekend | undefined)[]) {
case 'partial': case 'partial':
partial++ partial++
break break
case 'cancelled':
cancelled++
break
default: default:
missing++ missing++
} }
} }
return { full, partial, missing, total: weekends.length } return { full, partial, cancelled, missing, total: weekends.length }
} }
export function sessionIconClass(session: WeekendSession): string { export function sessionIconClass(session: WeekendSession): string {
if (session.source === 'cancelled') return 'si-cancelled'
if (session.source === 'none') return 'si-missing' if (session.source === 'none') return 'si-missing'
if (isSessionComplete(session.datasets)) return 'si-full' if (isSessionComplete(session.datasets)) return 'si-full'
return 'si-partial' return 'si-partial'

View File

@@ -137,6 +137,9 @@ export function DataLibraryPage() {
<span> <span>
<em className="dl-stat-partial">{stats.partial}</em> partial <em className="dl-stat-partial">{stats.partial}</em> partial
</span> </span>
<span>
<em className="dl-stat-cancelled">{stats.cancelled}</em> cancelled
</span>
<span> <span>
<em>{stats.missing}</em> missing <em>{stats.missing}</em> missing
</span> </span>
@@ -177,6 +180,10 @@ export function DataLibraryPage() {
<span className="dl-stat-label">Partial</span> <span className="dl-stat-label">Partial</span>
<span className="dl-stat-val dl-stat-partial">{stats.partial}</span> <span className="dl-stat-val dl-stat-partial">{stats.partial}</span>
</div> </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"> <div className="dl-stat">
<span className="dl-stat-label">Missing</span> <span className="dl-stat-label">Missing</span>
<span className="dl-stat-val">{stats.missing}</span> <span className="dl-stat-val">{stats.missing}</span>
@@ -294,7 +301,9 @@ export function DataLibraryPage() {
)} )}
</td> </td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}> <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` ? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full`
: '—'} : '—'}
</td> </td>

View File

@@ -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-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-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); } .badge-none { background: rgba(80,80,80,.12); color: var(--text-3); border: 1px solid var(--border); }
/* ── Dataset strip ── */ /* ── 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-full { color: var(--green); }
.dl-banner-stats em.dl-stat-partial { color: var(--yellow); } .dl-banner-stats em.dl-stat-partial { color: var(--yellow); }
.dl-banner-stats em.dl-stat-cancelled { color: #ff8a5c; }
.dl-footer-link { .dl-footer-link {
display: flex; display: flex;
@@ -1259,6 +1261,7 @@ a { color: inherit; text-decoration: none; }
} }
.dl-stat-full { color: var(--green); } .dl-stat-full { color: var(--green); }
.dl-stat-partial { color: var(--yellow); } .dl-stat-partial { color: var(--yellow); }
.dl-stat-cancelled { color: #ff8a5c; }
.dl-content { .dl-content {
display: flex; 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-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-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); } .session-icon.si-missing { background: rgba(50,50,50,0.5); color: var(--text-3); }
.dl-detail-wrap { .dl-detail-wrap {
@@ -4440,4 +4444,3 @@ a { color: inherit; text-decoration: none; }
background: #f82f34; background: #f82f34;
transform: translateY(-1px); transform: translateY(-1px);
} }

View File

@@ -59,11 +59,18 @@ describe('coverage helpers', () => {
meeting: {} as Weekend['meeting'], meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'partial', datasets: {} }], 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, full: 1,
partial: 1, partial: 1,
cancelled: 1,
missing: 1, missing: 1,
total: 3, total: 4,
}) })
}) })
}) })

View File

@@ -16,6 +16,7 @@ export interface Meeting {
date_start: string date_start: string
date_end: string date_end: string
year: number year: number
is_cancelled?: boolean
} }
export interface Session { export interface Session {
@@ -26,6 +27,7 @@ export interface Session {
date_start: string date_start: string
date_end: string date_end: string
gmt_offset: string gmt_offset: string
is_cancelled?: boolean
} }
export interface Driver { export interface Driver {
@@ -73,7 +75,7 @@ export interface EnrichedGrid {
} }
export interface RaceHub { export interface RaceHub {
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
session_key: number session_key: number
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
meeting?: Meeting meeting?: Meeting
@@ -158,12 +160,12 @@ export interface Lap {
export interface WeekendSession { export interface WeekendSession {
session: Session session: Session
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
datasets: Record<string, DatasetInfo> datasets: Record<string, DatasetInfo>
} }
export interface Weekend { export interface Weekend {
source: 'local' | 'partial' | 'none' source: 'local' | 'partial' | 'none' | 'cancelled'
meeting_key: number meeting_key: number
meeting: Meeting meeting: Meeting
sessions: WeekendSession[] sessions: WeekendSession[]

View File

@@ -430,6 +430,27 @@ func (s *Service) ingestSessionDatasets(sess models.Session) (Summary, error) {
coverage = make(map[string]store.CoverageEntry) 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 // 1. Ingest drivers
if cov, ok := coverage["drivers"]; ok && cov.Status == "complete" && !s.opts.Force { if cov, ok := coverage["drivers"]; ok && cov.Status == "complete" && !s.opts.Force {
s.opts.Progress.Step("drivers already complete for session %d, skipping", sessionKey) 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" return "completed"
} }
func statusForCancelled(dryRun bool) string {
if dryRun {
return "dry_run"
}
return "cancelled"
}
type fetchFunc[T any] func() (FetchResult, T, error) type fetchFunc[T any] func() (FetchResult, T, error)
func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) { func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) {

View File

@@ -325,6 +325,7 @@ func meetingToStore(m models.Meeting) store.Meeting {
DateStart: m.DateStart, DateStart: m.DateStart,
DateEnd: m.DateEnd, DateEnd: m.DateEnd,
Year: m.Year, Year: m.Year,
IsCancelled: m.IsCancelled,
} }
} }
@@ -338,6 +339,7 @@ func sessionToStore(s models.Session) store.Session {
DateStart: s.DateStart, DateStart: s.DateStart,
DateEnd: s.DateEnd, DateEnd: s.DateEnd,
GMTOffset: s.GMTOffset, GMTOffset: s.GMTOffset,
IsCancelled: s.IsCancelled,
} }
} }

View File

@@ -24,6 +24,8 @@ type Meeting struct {
DateStart string `json:"date_start"` DateStart string `json:"date_start"`
DateEnd string `json:"date_end"` DateEnd string `json:"date_end"`
Year int `json:"year"` Year int `json:"year"`
IsCancelled bool `json:"is_cancelled"`
} }
type Session struct { type Session struct {
@@ -38,6 +40,8 @@ type Session struct {
DateStart string `json:"date_start"` DateStart string `json:"date_start"`
DateEnd string `json:"date_end"` DateEnd string `json:"date_end"`
GMTOffset string `json:"gmt_offset"` GMTOffset string `json:"gmt_offset"`
IsCancelled bool `json:"is_cancelled"`
} }
// TyreCompound represents the type of tyre compound used. // 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) LaneDuration float64 `json:"lane_duration"` // pit lane time (entry to exit)
LapNumber int `json:"lap_number"` LapNumber int `json:"lap_number"`
MeetingKey int `json:"meeting_key"` 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"` SessionKey int `json:"session_key"`
StopDuration float64 `json:"stop_duration"` // stationary time only StopDuration float64 `json:"stop_duration"` // stationary time only
} }
@@ -186,7 +190,7 @@ type Interval struct {
Date string `json:"date"` Date string `json:"date"`
DriverNumber int `json:"driver_number"` DriverNumber int `json:"driver_number"`
GapToLeader *float64 `json:"gap_to_leader"` // null when leading 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"` MeetingKey int `json:"meeting_key"`
SessionKey int `json:"session_key"` SessionKey int `json:"session_key"`
} }
@@ -228,7 +232,7 @@ type Weather struct {
} }
type CarData struct { type CarData struct {
Brake int `json:"brake"` // 0-100 Brake int `json:"brake"` // 0-100
Date string `json:"date"` Date string `json:"date"`
DriverNumber int `json:"driver_number"` DriverNumber int `json:"driver_number"`
DRS int `json:"drs"` // 0=off, 8=eligible, 10=open DRS int `json:"drs"` // 0=off, 8=eligible, 10=open

View File

@@ -23,6 +23,8 @@ func meetingToModel(m store.Meeting) models.Meeting {
DateStart: m.DateStart, DateStart: m.DateStart,
DateEnd: m.DateEnd, DateEnd: m.DateEnd,
Year: m.Year, Year: m.Year,
IsCancelled: m.IsCancelled,
} }
} }
@@ -36,6 +38,8 @@ func sessionToModel(s store.Session) models.Session {
DateStart: s.DateStart, DateStart: s.DateStart,
DateEnd: s.DateEnd, DateEnd: s.DateEnd,
GMTOffset: s.GMTOffset, GMTOffset: s.GMTOffset,
IsCancelled: s.IsCancelled,
} }
} }

View File

@@ -55,3 +55,23 @@ func datasetsFromCounts(meetingAvailable, sessionAvailable bool, counts store.Se
} }
return ds 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
}

View File

@@ -10,9 +10,10 @@ const (
DataSourceNone = "none" DataSourceNone = "none"
DataSourceOpenF1 = "openf1" DataSourceOpenF1 = "openf1"
ResponseSourceLocal = "local" ResponseSourceLocal = "local"
ResponseSourceNone = "none" ResponseSourceNone = "none"
ResponseSourcePartial = "partial" ResponseSourcePartial = "partial"
ResponseSourceCancelled = "cancelled"
) )
// DatasetInfo describes availability of a single dataset. // DatasetInfo describes availability of a single dataset.

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"github.com/AmanTahiliani/box-box/internal/models" "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. // 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) datasets := datasetsFromCounts(true, true, counts)
sessionModel := sessionToModel(sess) sessionModel := sessionToModel(sess)
if sess.IsCancelled {
datasets = cancelledDatasets()
}
if datasets["starting_grid"].Status == DatasetStatusMissing && !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) { if datasets["starting_grid"].Status == DatasetStatusMissing && !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) {
datasets["starting_grid"] = skippedNA() datasets["starting_grid"] = skippedNA()
} }
out.Sessions = append(out.Sessions, WeekendSession{ out.Sessions = append(out.Sessions, WeekendSession{
Session: sessionModel, Session: sessionModel,
Source: responseSource(datasets), Source: sessionSource(sess, datasets),
Datasets: 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) out.DefaultSessionKey = pickDefaultSession(out.Sessions)
return out, nil 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 { func weekendSource(sessions []WeekendSession) string {
if len(sessions) == 0 { if len(sessions) == 0 {
return ResponseSourceNone return ResponseSourceNone

View File

@@ -106,6 +106,12 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub.Datasets["meeting"] = availableLocal(1) 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) driverLinks, err := s.store.ListSessionDrivers(sessionKey)
if err != nil { if err != nil {
return RaceHub{}, err return RaceHub{}, err

View File

@@ -12,8 +12,8 @@ func TestCoverageCRUD(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 6 { if version != 7 {
t.Fatalf("SchemaVersion() = %d, want 6", version) t.Fatalf("SchemaVersion() = %d, want 7", version)
} }
// Verify session_coverage table exists // Verify session_coverage table exists

View File

@@ -16,8 +16,8 @@ func (s *Store) UpsertMeeting(m Meeting) error {
INSERT INTO meetings ( INSERT INTO meetings (
meeting_key, meeting_name, meeting_official_name, location, meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name, 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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(meeting_key) DO UPDATE SET ON CONFLICT(meeting_key) DO UPDATE SET
meeting_name = excluded.meeting_name, meeting_name = excluded.meeting_name,
meeting_official_name = excluded.meeting_official_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_start = excluded.date_start,
date_end = excluded.date_end, date_end = excluded.date_end,
year = excluded.year, year = excluded.year,
is_cancelled = excluded.is_cancelled,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
m.MeetingKey, m.MeetingKey,
@@ -44,6 +45,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
nullString(m.DateStart), nullString(m.DateStart),
nullString(m.DateEnd), nullString(m.DateEnd),
m.Year, m.Year,
boolInt(m.IsCancelled),
m.UpdatedAt.Unix(), m.UpdatedAt.Unix(),
) )
if err != nil { if err != nil {
@@ -56,6 +58,7 @@ func (s *Store) UpsertMeeting(m Meeting) error {
func (s *Store) GetMeeting(meetingKey int) (Meeting, error) { func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
var m Meeting var m Meeting
var updatedAt int64 var updatedAt int64
var isCancelled int
var officialName, location, countryCode, countryName sql.NullString var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
@@ -63,7 +66,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT meeting_key, meeting_name, meeting_official_name, location, SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name, 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 FROM meetings
WHERE meeting_key = ? WHERE meeting_key = ?
`, meetingKey).Scan( `, meetingKey).Scan(
@@ -79,6 +82,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&m.Year, &m.Year,
&isCancelled,
&updatedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -96,6 +100,7 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
m.GMTOffset = gmtOffset.String m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String m.DateStart = dateStart.String
m.DateEnd = dateEnd.String m.DateEnd = dateEnd.String
m.IsCancelled = isCancelled != 0
m.UpdatedAt = time.Unix(updatedAt, 0) m.UpdatedAt = time.Unix(updatedAt, 0)
return m, nil return m, nil
} }
@@ -129,7 +134,7 @@ func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT meeting_key, meeting_name, meeting_official_name, location, SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name, 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 FROM meetings
WHERE year = ? WHERE year = ?
ORDER BY date_start ASC, meeting_key ASC ORDER BY date_start ASC, meeting_key ASC
@@ -151,8 +156,8 @@ func (s *Store) UpsertSession(sess Session) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO sessions ( INSERT INTO sessions (
session_key, meeting_key, session_name, session_type, 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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET ON CONFLICT(session_key) DO UPDATE SET
meeting_key = excluded.meeting_key, meeting_key = excluded.meeting_key,
session_name = excluded.session_name, session_name = excluded.session_name,
@@ -161,6 +166,7 @@ func (s *Store) UpsertSession(sess Session) error {
date_start = excluded.date_start, date_start = excluded.date_start,
date_end = excluded.date_end, date_end = excluded.date_end,
gmt_offset = excluded.gmt_offset, gmt_offset = excluded.gmt_offset,
is_cancelled = excluded.is_cancelled,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`, `,
sess.SessionKey, sess.SessionKey,
@@ -171,6 +177,7 @@ func (s *Store) UpsertSession(sess Session) error {
nullString(sess.DateStart), nullString(sess.DateStart),
nullString(sess.DateEnd), nullString(sess.DateEnd),
nullString(sess.GMTOffset), nullString(sess.GMTOffset),
boolInt(sess.IsCancelled),
sess.UpdatedAt.Unix(), sess.UpdatedAt.Unix(),
) )
if err != nil { if err != nil {
@@ -183,12 +190,13 @@ func (s *Store) UpsertSession(sess Session) error {
func (s *Store) GetSession(sessionKey int) (Session, error) { func (s *Store) GetSession(sessionKey int) (Session, error) {
var sess Session var sess Session
var updatedAt int64 var updatedAt int64
var isCancelled int
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString var dateStart, dateEnd, gmtOffset sql.NullString
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT session_key, meeting_key, session_name, session_type, 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 FROM sessions
WHERE session_key = ? WHERE session_key = ?
`, sessionKey).Scan( `, sessionKey).Scan(
@@ -200,6 +208,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&gmtOffset, &gmtOffset,
&isCancelled,
&updatedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -212,6 +221,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
sess.DateStart = dateStart.String sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String sess.GMTOffset = gmtOffset.String
sess.IsCancelled = isCancelled != 0
sess.UpdatedAt = time.Unix(updatedAt, 0) sess.UpdatedAt = time.Unix(updatedAt, 0)
return sess, nil return sess, nil
} }
@@ -220,7 +230,7 @@ func (s *Store) GetSession(sessionKey int) (Session, error) {
func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) { func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT session_key, meeting_key, session_name, session_type, 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 FROM sessions
WHERE meeting_key = ? WHERE meeting_key = ?
ORDER BY date_start ASC, session_key ASC ORDER BY date_start ASC, session_key ASC
@@ -238,6 +248,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
for rows.Next() { for rows.Next() {
var m Meeting var m Meeting
var updatedAt int64 var updatedAt int64
var isCancelled int
var officialName, location, countryCode, countryName sql.NullString var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
@@ -255,6 +266,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&m.Year, &m.Year,
&isCancelled,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -271,6 +283,7 @@ func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
m.GMTOffset = gmtOffset.String m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String m.DateStart = dateStart.String
m.DateEnd = dateEnd.String m.DateEnd = dateEnd.String
m.IsCancelled = isCancelled != 0
m.UpdatedAt = time.Unix(updatedAt, 0) m.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, m) out = append(out, m)
} }
@@ -282,6 +295,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
for rows.Next() { for rows.Next() {
var sess Session var sess Session
var updatedAt int64 var updatedAt int64
var isCancelled int
var circuitKey sql.NullInt64 var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString var dateStart, dateEnd, gmtOffset sql.NullString
@@ -294,6 +308,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
&dateStart, &dateStart,
&dateEnd, &dateEnd,
&gmtOffset, &gmtOffset,
&isCancelled,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -305,6 +320,7 @@ func scanSessions(rows *sql.Rows) ([]Session, error) {
sess.DateStart = dateStart.String sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String sess.GMTOffset = gmtOffset.String
sess.IsCancelled = isCancelled != 0
sess.UpdatedAt = time.Unix(updatedAt, 0) sess.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, sess) out = append(out, sess)
} }

View 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;

View File

@@ -68,6 +68,7 @@ type Meeting struct {
DateStart string DateStart string
DateEnd string DateEnd string
Year int Year int
IsCancelled bool
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -81,6 +82,7 @@ type Session struct {
DateStart string DateStart string
DateEnd string DateEnd string
GMTOffset string GMTOffset string
IsCancelled bool
UpdatedAt time.Time UpdatedAt time.Time
} }

View File

@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 6 { if version != 7 {
t.Fatalf("SchemaVersion() = %d, want 6", version) t.Fatalf("SchemaVersion() = %d, want 7", version)
} }
tables := []string{ tables := []string{
@@ -105,6 +105,18 @@ func TestMigrationsAreIdempotent(t *testing.T) {
if count != 1 { if count != 1 {
t.Fatalf("schema_migrations v5 count = %d, want 1", count) 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) { func TestRawPayloadInsertAndRead(t *testing.T) {