From 2c9db0213c1d977fc89b94965912fc785a79cb64 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 02:31:35 -0400 Subject: [PATCH] Add local data navigation UI --- .../refactor/19-phase-11-weekend-ingestion.md | 40 +++ documentations/refactor/README.md | 6 +- .../cursor-phase-10-navigation-ui-prompt.md | 83 ----- ...ursor-phase-11-weekend-ingestion-prompt.md | 78 +++++ frontend/src/api.ts | 28 +- .../src/components/LocalDataNavigator.tsx | 305 ++++++++++++++++++ frontend/src/pages/RaceHubPage.tsx | 20 +- frontend/src/styles/app.css | 117 +++++++ frontend/src/test/LocalDataNavigator.test.tsx | 151 +++++++++ frontend/src/types.ts | 14 + tests/race-hub.spec.ts | 14 + 11 files changed, 758 insertions(+), 98 deletions(-) create mode 100644 documentations/refactor/19-phase-11-weekend-ingestion.md delete mode 100644 documentations/refactor/cursor-phase-10-navigation-ui-prompt.md create mode 100644 documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md create mode 100644 frontend/src/components/LocalDataNavigator.tsx create mode 100644 frontend/src/test/LocalDataNavigator.test.tsx diff --git a/documentations/refactor/19-phase-11-weekend-ingestion.md b/documentations/refactor/19-phase-11-weekend-ingestion.md new file mode 100644 index 0000000..abc9988 --- /dev/null +++ b/documentations/refactor/19-phase-11-weekend-ingestion.md @@ -0,0 +1,40 @@ +# Phase 11 Weekend Ingestion + +## Purpose + +Phase 10 made local data navigable in the Web UI, but the app still needs a +practical way to populate a complete weekend. Phase 11 should make ingestion +work at the same shape users browse: meeting/weekend first, then sessions. + +This is a backend/CLI slice for Cursor. + +## Scope + +Add or refine CLI ingestion so a user can ingest a whole meeting/weekend into +the domain database without manually running one command per session. + +The target workflow is: + +- ingest meeting metadata and sessions for a `meeting_key`; +- for each session in that meeting, ingest Race Hub datasets; +- report per-session success, partial failure, and row counts clearly; +- keep raw payload provenance. + +## Guardrails + +- Do not remove single-session ingestion. +- Do not fetch data from React. +- Do not make failed optional analytics endpoints destroy already-ingested + meeting/session metadata. +- Keep tests offline with fake sources. +- Be careful with live/current sessions; completed historical sessions are the + primary target. + +## Acceptance Criteria + +- A single CLI path can ingest all sessions for a meeting. +- Ingestion summaries make per-session results clear. +- Existing `--ingest-session` behavior still works. +- Store/query/web/frontend tests still pass. +- Add focused ingestion tests for full-weekend orchestration and partial + failures where practical. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index 64b82be..5df808e 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -71,8 +71,10 @@ not implementation tickets yet. raw session keys. - [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for adding local-first season/weekend navigation around Race Hub. -- [Cursor Phase 10 Prompt](cursor-phase-10-navigation-ui-prompt.md): current - handoff prompt for the next frontend phase. +- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend + slice for making one command ingest a whole race weekend into the local DB. +- [Cursor Phase 11 Prompt](cursor-phase-11-weekend-ingestion-prompt.md): + current handoff prompt for the next backend phase. ## External References diff --git a/documentations/refactor/cursor-phase-10-navigation-ui-prompt.md b/documentations/refactor/cursor-phase-10-navigation-ui-prompt.md deleted file mode 100644 index 83b64f8..0000000 --- a/documentations/refactor/cursor-phase-10-navigation-ui-prompt.md +++ /dev/null @@ -1,83 +0,0 @@ -# Prompt For Cursor: Phase 10 Navigation UI - -You are working in the `box-box` repository on Phase 10. This is a frontend -phase, but keep it pragmatic and low-context: build functional local-first -navigation around the existing Race Hub without redesigning the whole app. - -## Read First - -Open these files first: - -- `documentations/refactor/18-phase-10-navigation-ui.md` -- `frontend/src/pages/RaceHubPage.tsx` -- `frontend/src/api.ts` -- `frontend/src/types.ts` -- `frontend/src/main.tsx` -- `frontend/src/styles.css` -- `tests/race-hub.spec.ts` - -Only open older docs if you are blocked. - -## Backend APIs Available - -- `GET /api/v1/seasons` - - returns local years, newest first, e.g. `[2025]`. -- `GET /api/v1/meetings?year=2025&source=local` - - returns locally ingested meetings for the year. -- `GET /api/v1/weekend?meeting_key=1229` - - returns meeting metadata, sessions, `default_session_key`, and per-session - dataset coverage. - -Use `source=local` for meetings so React does not fall back to OpenF1. - -## Goal - -Let users browse local data into Race Hub without knowing a raw `session_key`. - -## Work To Do - -1. Add TypeScript types and API functions for seasons, local meetings, and - weekend details. -2. Add a simple local data navigator in the React app: - - year selector/list; - - meetings for selected year; - - sessions for selected weekend; - - dataset coverage hints. -3. Selecting a session should navigate to `/race-hub?session_key=`. -4. Keep the current manual session key entry as a fallback. -5. Preserve the existing Race Hub tabs and analytics views. -6. Add focused tests where practical. -7. Update Playwright coverage if a stable seeded navigation path is easy. - -## Design Notes - -- Keep it dense and operational, not a marketing page. -- Avoid card-heavy dashboard sludge. -- Reuse existing type, spacing, tab, and table conventions where possible. -- Mobile should remain usable. - -## Do Not Do - -- Do not fetch OpenF1 from React. -- Do not remove direct `session_key` routing. -- Do not introduce a new UI framework. -- Do not touch backend unless you find a blocking API bug. - -## Verification - -Run: - -```bash -cd frontend && npm test -- --run -cd frontend && npm run build -npm run test:e2e -``` - -## Report Back - -Summarize: - -- files changed; -- navigation behavior added; -- tests run and results; -- follow-up polish or data needs. diff --git a/documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md b/documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md new file mode 100644 index 0000000..34c5b54 --- /dev/null +++ b/documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md @@ -0,0 +1,78 @@ +# Prompt For Cursor: Phase 11 Weekend Ingestion + +You are working in the `box-box` repository on Phase 11. The Web UI can now +browse local years, meetings, and sessions. Your task is to make the CLI able +to populate a whole race weekend/meeting in one backend ingestion flow. + +## Read First + +Open these files first: + +- `documentations/refactor/19-phase-11-weekend-ingestion.md` +- `cmd/main.go` +- `internal/ingest/ingest.go` +- `internal/ingest/ingest_test.go` +- `internal/ingest/openf1.go` +- `internal/store/meetings.go` +- `internal/query/navigation.go` + +Only open older docs if you are blocked. + +## Goal + +Make meeting/weekend ingestion useful for the local-first Web UI. A user should +be able to ingest a meeting and have all sessions for that meeting populated +with Race Hub datasets where available. + +## Current Shape + +The project already has: + +- `--ingest-year` +- `--ingest-meeting` +- `--ingest-session` +- session-level Race Hub ingestion datasets; +- local navigation APIs and UI that depend on ingested meeting/session data. + +Confirm the exact current behavior before editing. If `--ingest-meeting` +currently only stores meeting/session metadata, extend it or add a clearly named +flag. Prefer the least surprising CLI behavior. + +## Work To Do + +1. Add meeting/weekend orchestration that fetches sessions for a meeting and + ingests Race Hub datasets for each session. +2. Preserve single-session ingestion behavior. +3. Return/report per-session summaries clearly. +4. Keep raw payload provenance for all fetched endpoints. +5. Make partial failures visible without erasing successful session data. +6. Add focused offline tests with fake sources. + +## Guardrails + +- Do not fetch OpenF1 from React. +- Do not introduce background ingestion from normal page views. +- Do not persist high-volume car telemetry in this phase. +- Do not break existing e2e seed behavior. +- Keep completed historical sessions as the default mental model. + +## Verification + +Run: + +```bash +go test ./internal/ingest/... ./internal/store/... ./internal/query/... ./internal/web/... +go build -o /private/tmp/box-box ./cmd/main.go +cd frontend && npm test -- --run +cd frontend && npm run build +npm run test:e2e +``` + +## Report Back + +Summarize: + +- files changed; +- CLI behavior added or changed; +- tests run and results; +- follow-up risks, especially around OpenF1 rate limits or partial sessions. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6bfb863..e0eb3fb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { RaceHub } from './types' +import type { Meeting, RaceHub, Weekend } from './types' export async function fetchRaceHub(sessionKey: number): Promise { const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) @@ -7,3 +7,29 @@ export async function fetchRaceHub(sessionKey: number): Promise { } return res.json() } + +export async function fetchSeasons(): Promise { + const res = await fetch('/api/v1/seasons') + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + const years = await res.json() + return Array.isArray(years) ? years : [] +} + +export async function fetchLocalMeetings(year: number): Promise { + const res = await fetch(`/api/v1/meetings?year=${year}&source=local`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + const meetings = await res.json() + return Array.isArray(meetings) ? meetings : [] +} + +export async function fetchWeekend(meetingKey: number): Promise { + const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} diff --git a/frontend/src/components/LocalDataNavigator.tsx b/frontend/src/components/LocalDataNavigator.tsx new file mode 100644 index 0000000..89f2815 --- /dev/null +++ b/frontend/src/components/LocalDataNavigator.tsx @@ -0,0 +1,305 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' +import type { DatasetInfo, Meeting, WeekendSession } from '../types' + +const RACE_HUB_DATASETS = [ + 'meeting', + 'session', + 'drivers', + 'results', + 'starting_grid', + 'stints', + 'pit_stops', + 'positions', + 'race_control', + 'weather', + 'laps', +] as const + +export function countRaceHubDatasets(datasets: Record): { available: number; total: number } { + const total = RACE_HUB_DATASETS.length + const available = RACE_HUB_DATASETS.filter((key) => datasets[key]?.status === 'available').length + return { available, total } +} + +export function formatCoverageHint(datasets: Record): string { + const { available, total } = countRaceHubDatasets(datasets) + return `${available}/${total}` +} + +function sourceBadge(source: WeekendSession['source']) { + switch (source) { + case 'local': + return Local + case 'partial': + return Partial + default: + return None + } +} + +function formatMeetingDates(meeting: Meeting): string { + const start = meeting.date_start?.slice(0, 10) + const end = meeting.date_end?.slice(0, 10) + if (start && end && start !== end) return `${start} – ${end}` + return start || end || '—' +} + +interface Props { + onSelectSession?: (sessionKey: number) => void +} + +export function LocalDataNavigator({ onSelectSession }: Props) { + const navigate = useNavigate() + const [selectedYear, setSelectedYear] = useState(null) + const [selectedMeetingKey, setSelectedMeetingKey] = useState(null) + + const seasonsQuery = useQuery({ + queryKey: ['seasons'], + queryFn: fetchSeasons, + }) + + const meetingsQuery = useQuery({ + queryKey: ['meetings', selectedYear], + queryFn: () => fetchLocalMeetings(selectedYear!), + enabled: selectedYear != null, + }) + + const weekendQuery = useQuery({ + queryKey: ['weekend', selectedMeetingKey], + queryFn: () => fetchWeekend(selectedMeetingKey!), + enabled: selectedMeetingKey != null, + }) + + useEffect(() => { + if (seasonsQuery.data?.length && selectedYear == null) { + setSelectedYear(seasonsQuery.data[0]) + } + }, [seasonsQuery.data, selectedYear]) + + function handleSelectSession(sessionKey: number) { + if (onSelectSession) { + onSelectSession(sessionKey) + return + } + navigate({ to: '/race-hub', search: { session_key: sessionKey } }) + } + + function handleSelectYear(year: number) { + setSelectedYear(year) + setSelectedMeetingKey(null) + } + + function handleSelectMeeting(meetingKey: number) { + setSelectedMeetingKey((prev) => (prev === meetingKey ? null : meetingKey)) + } + + if (seasonsQuery.isLoading) { + return
loading local seasons…
+ } + + if (seasonsQuery.isError) { + return ( +
+ {seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'} +
+ ) + } + + const seasons = seasonsQuery.data ?? [] + + if (seasons.length === 0) { + return ( +
+
Local Data
+
+
No ingested seasons yet
+
+ Ingest a session with box-box --ingest-session <key>, then browse + here or enter a session key below. +
+
+
+ ) + } + + const meetings = meetingsQuery.data ?? [] + const weekend = weekendQuery.data + + return ( +
+
+ Local Data +
+ {seasons.map((year) => ( + + ))} +
+
+ + {meetingsQuery.isLoading && ( +
loading meetings…
+ )} + + {meetingsQuery.isError && ( +
+ {meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'} +
+ )} + + {!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && ( +
No meetings ingested for {selectedYear}.
+ )} + + {meetings.length > 0 && ( +
+
+ Meetings + {meetings.length} +
+
+ + + + + + + + + + + {meetings.map((meeting) => { + const selected = meeting.meeting_key === selectedMeetingKey + return ( + + + + + + + ) + })} + +
Grand PrixCountryDatesOpen
+ {meeting.meeting_name} + {meeting.circuit_short_name && meeting.circuit_short_name !== meeting.meeting_name && ( + {meeting.circuit_short_name} + )} + + {meeting.country_code || meeting.country_name} + + {formatMeetingDates(meeting)} + + +
+
+
+ )} + + {selectedMeetingKey != null && weekendQuery.isLoading && ( +
loading sessions…
+ )} + + {selectedMeetingKey != null && weekendQuery.isError && ( +
+ {weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'} +
+ )} + + {weekend && ( +
+
+ {weekend.meeting.meeting_name} Sessions + {weekend.sessions.length} +
+ + {weekend.sessions.length === 0 ? ( +
No sessions stored for this meeting.
+ ) : ( +
+ + + + + + + + + + + + {weekend.sessions.map(({ session, source, datasets }) => { + const coverage = formatCoverageHint(datasets) + const isDefault = session.session_key === weekend.default_session_key + return ( + + + + + + + + ) + })} + +
SessionTypeCoverageSourceOpen
+ {session.session_name} + {isDefault && default} + {session.session_key} + + {session.session_type} + + + {coverage} + + + {sourceBadge(source)} + +
+
+ )} +
+ )} +
+ ) +} + +function SessionCoverageDots({ datasets }: { datasets: Record }) { + return ( +