diff --git a/documentations/refactor/08-v1-scope-and-phasing.md b/documentations/refactor/08-v1-scope-and-phasing.md index a4a318c..cab2854 100644 --- a/documentations/refactor/08-v1-scope-and-phasing.md +++ b/documentations/refactor/08-v1-scope-and-phasing.md @@ -187,3 +187,10 @@ After v1: - Add Dataset Status, Strategy, and Position Evolution views. - Use real local-first data where available and honest missing states otherwise. - Continue frontend work with Claude. + +### Phase 7: Analytics Data Foundation + +- Return to Cursor for backend work. +- Add local-first store, ingestion, and Race Hub API support for stints, + positions, and related analytics datasets. +- Keep React Strategy/Position views honest until real data is available. diff --git a/documentations/refactor/15-phase-7-analytics-data-foundation.md b/documentations/refactor/15-phase-7-analytics-data-foundation.md new file mode 100644 index 0000000..7df13a8 --- /dev/null +++ b/documentations/refactor/15-phase-7-analytics-data-foundation.md @@ -0,0 +1,52 @@ +# Phase 7 Analytics Data Foundation + +## Purpose + +Phase 7 returns to backend work. Phase 6 added honest frontend placeholders for +strategy and position evolution, but the Race Hub API does not yet expose the +local datasets needed to draw those views. + +The goal is to expand the local store, ingestion, and Race Hub read model with +the first analytics datasets. + +## Scope + +Add local-first support for: + +- laps; +- stints; +- pit stops; +- race control; +- weather; +- positions, if volume and schema stay manageable. + +Prioritize stints and positions because they unlock the Strategy and Position +Evolution views. + +## Backend Work + +Expected changes: + +- add SQLite tables and migrations for the selected datasets; +- add store upsert/read methods; +- extend `internal/ingest` session ingestion; +- extend `internal/query.RaceHub`; +- extend `/api/v1/race-hub` metadata counts; +- keep raw payload provenance for every fetched endpoint. + +## Guardrails + +- Keep ingestion idempotent. +- Keep tests offline. +- Do not fetch OpenF1 directly from React. +- Do not persist high-volume car telemetry yet. +- If positions are too large for this phase, document the limit and implement + stints/pits first. + +## Acceptance Criteria + +- Store migrations and CRUD tests pass. +- Ingestion writes new datasets and raw payloads. +- Race Hub API exposes new datasets with metadata. +- Existing React placeholders can detect available stints/positions. +- Focused Go tests pass. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index 86bfc5f..861bb38 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -61,8 +61,10 @@ not implementation tickets yet. implementation slice for the production Web UI. - [14 Phase 6 React Race Hub Analytics](14-phase-6-react-race-hub-analytics.md): next frontend slice for strategy, position, and richer Race Hub views. -- [Claude Phase 6 Prompt](claude-phase-6-react-race-hub-analytics-prompt.md): - current handoff prompt for the next Claude frontend phase. +- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md): + backend slice for laps, stints, pits, race control, weather, and positions. +- [Cursor Phase 7 Prompt](cursor-phase-7-analytics-data-foundation-prompt.md): + current handoff prompt for the next Cursor backend phase. ## External References diff --git a/documentations/refactor/claude-phase-6-react-race-hub-analytics-prompt.md b/documentations/refactor/claude-phase-6-react-race-hub-analytics-prompt.md deleted file mode 100644 index fd050c0..0000000 --- a/documentations/refactor/claude-phase-6-react-race-hub-analytics-prompt.md +++ /dev/null @@ -1,76 +0,0 @@ -# Claude Prompt: Phase 6 React Race Hub Analytics - -You are continuing the React frontend work for `box-box`. - -Phase 5 added the Vite + React + TypeScript app under `frontend/` and built the -first Race Hub route using: - -```text -GET /api/v1/race-hub?session_key=... -``` - -Your task is Phase 6: expand Race Hub into a richer analysis surface while -keeping the design F1-native and operational. - -## Read First - -Read: - -- `frontend/README.md` -- `frontend/src/pages/RaceHubPage.tsx` -- `frontend/src/styles/app.css` -- `frontend/src/types.ts` -- `documentations/refactor/06-visual-design-direction.md` -- `documentations/refactor/13-phase-5-react-race-hub.md` -- `documentations/refactor/14-phase-6-react-race-hub-analytics.md` -- `internal/query/racehub.go` - -## Goal - -Add a more useful Race Hub interaction model: tabs or segmented views for -classification, grid, strategy, position evolution, and dataset status. - -## Required Work - -1. Add a compact Race Hub tab/segmented control. -2. Preserve the existing classification and grid views. -3. Add a Dataset Status view that makes available/missing datasets very clear. -4. Add Strategy and Position Evolution views with honest missing states if the - backend does not yet expose enough data. -5. Improve the desktop information hierarchy so the timing table feels more - legible and intentional. -6. Preserve the mobile table fix: active phone columns must fit the viewport. -7. Add or update frontend tests. - -## Guardrails - -- Do not fake analytics data. -- Do not fetch OpenF1 directly from React. -- Do not start live timing React work. -- Avoid card sludge and generic SaaS dashboard patterns. -- Keep old Web UI behavior intact. -- Make backend changes only if they are tiny API contract fixes. - -## Verification - -Run: - -```bash -cd frontend -npm test -- --run -npm run build -cd .. -go build -o /tmp/box-box ./cmd/main.go -``` - -Visually check desktop and phone widths. - -## Final Response - -Report: - -- views/components added; -- API datasets used; -- tests/build results; -- desktop/mobile visual notes; -- any backend data needed for real strategy/position charts. diff --git a/documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md b/documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md new file mode 100644 index 0000000..d497ee8 --- /dev/null +++ b/documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md @@ -0,0 +1,72 @@ +# Cursor Prompt: Phase 7 Analytics Data Foundation + +You are working in the `box-box` repository. + +Phase 6 added React Race Hub analytics tabs, but Strategy and Position Evolution +still show honest missing states because the backend does not expose stints or +position samples in `/api/v1/race-hub`. + +Your task is Phase 7: expand the local-first backend data foundation for Race +Hub analytics. + +## Read First + +- `CLAUDE.md` +- `documentations/refactor/15-phase-7-analytics-data-foundation.md` +- `internal/store/*` +- `internal/ingest/*` +- `internal/query/racehub.go` +- `internal/api/openf1.go` +- `internal/models/types.go` +- `frontend/src/components/StrategyView.tsx` +- `frontend/src/components/PositionEvolutionView.tsx` + +## Goal + +Add backend support for the datasets needed by strategy and position views, +prioritizing stints and positions. + +## Required Work + +1. Add a new SQLite migration for selected analytics tables. +2. Add store structs, upserts, and reads. +3. Extend session ingestion to fetch and store: + - stints; + - pit stops if straightforward; + - positions if volume is acceptable; + - race control and weather if scoped cleanly. +4. Store raw payloads for every fetched endpoint. +5. Extend `internal/query.RaceHub` with available analytics datasets. +6. Update dataset metadata counts. +7. Add offline tests with fake OpenF1 source data. + +## Guardrails + +- Do not fake frontend data. +- Do not fetch OpenF1 from React. +- Do not add high-volume car telemetry. +- Keep migrations idempotent. +- Keep existing Phase 5/6 React behavior working. + +## Verification + +Run: + +```bash +go test ./internal/store/... ./internal/ingest/... ./internal/query/... ./internal/web/... +go build -o /tmp/box-box ./cmd/main.go +cd frontend && npm test -- --run && npm run build +``` + +If `go test ./...` fails only on OpenF1 network integration tests, report it as +unrelated. + +## Final Response + +Report: + +- tables added; +- datasets ingested; +- Race Hub API fields added; +- tests/builds run; +- whether frontend Strategy/Position tabs now have real data available. diff --git a/frontend/src/components/DatasetStatusView.tsx b/frontend/src/components/DatasetStatusView.tsx new file mode 100644 index 0000000..c24c6da --- /dev/null +++ b/frontend/src/components/DatasetStatusView.tsx @@ -0,0 +1,69 @@ +import type { DatasetInfo } from '../types' + +interface Props { + datasets: Record +} + +const KNOWN_DATASETS: { key: string; label: string }[] = [ + { key: 'meeting', label: 'Meeting' }, + { key: 'session', label: 'Session' }, + { key: 'drivers', label: 'Drivers' }, + { key: 'results', label: 'Results' }, + { key: 'starting_grid', label: 'Starting Grid' }, +] + +export function DatasetStatusView({ datasets }: Props) { + const entries = KNOWN_DATASETS.map(({ key, label }) => ({ + key, + label, + info: datasets[key] as DatasetInfo | undefined, + })) + + const available = entries.filter((e) => e.info?.status === 'available').length + const total = entries.length + + return ( +
+
+ + {available}/{total} datasets available locally + + {available < total && ( + + Re-run box-box --ingest-session <key> after backend + support exists for missing datasets. + + )} +
+ + + + + + + + + + + {entries.map(({ key, label, info }) => ( + + + + + + ))} + +
DatasetStatusRecords
+ {label} + + {info?.status === 'available' ? ( + Local + ) : ( + Missing + )} + + {info?.count != null ? info.count : '—'} +
+
+ ) +} diff --git a/frontend/src/components/PositionEvolutionView.tsx b/frontend/src/components/PositionEvolutionView.tsx new file mode 100644 index 0000000..f04c180 --- /dev/null +++ b/frontend/src/components/PositionEvolutionView.tsx @@ -0,0 +1,80 @@ +import type { EnrichedResult, EnrichedGrid } from '../types' +import { gridDelta, gridDeltaClass } from '../utils' + +interface Props { + results: EnrichedResult[] + grid: EnrichedGrid[] + hasPositions: boolean +} + +export function PositionEvolutionView({ results, grid, hasPositions }: Props) { + if (!hasPositions) { + return ( +
+
+ Lap-by-lap positions not available. The backend does not + yet expose position samples in /api/v1/race-hub. Evolution + charts require per-driver position per lap. +
+ + {results.length > 0 && grid.length > 0 && ( + <> +
+ Grid → Finish + net positions gained/lost +
+ + + + + + + + + + + + {results.map((r) => { + const gridPos = + grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 + return ( + + + + + + + + ) + })} + +
DriverTeamGridFinishΔ
+ + {r.name_acronym || r.driver_number} + + + {r.team_name} + + {gridPos || '—'} + {r.position} + + {gridDelta(r.position, gridPos)} + +
+ + )} +
+ ) + } + + // Placeholder for when position samples are available + return ( +
Position evolution chart: not yet implemented.
+ ) +} diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx new file mode 100644 index 0000000..6a60c5f --- /dev/null +++ b/frontend/src/components/StrategyView.tsx @@ -0,0 +1,66 @@ +import type { EnrichedResult } from '../types' + +interface Props { + results: EnrichedResult[] + hasStints: boolean +} + +export function StrategyView({ results, hasStints }: Props) { + if (!hasStints) { + return ( +
+
+ Stints not available. The backend does not yet expose + tyre compound and stint ranges in /api/v1/race-hub. Strategy + charts require per-driver stints: compound, lap_start, lap_end. +
+ + {results.length > 0 && ( + <> +
+ Laps Completed + from results — hint at pit count +
+ + + + + + + + + + {results.map((r) => ( + + + + + + ))} + +
PDriverLaps
+ {r.position} + + + {r.name_acronym || r.driver_number} + + 0 ? 'var(--text)' : 'var(--text-3)' }}> + {r.number_of_laps > 0 ? r.number_of_laps : '—'} +
+ + )} +
+ ) + } + + // Placeholder for when stints data is available + return ( +
Strategy chart: not yet implemented.
+ ) +} diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx new file mode 100644 index 0000000..9ed10c5 --- /dev/null +++ b/frontend/src/components/TabBar.tsx @@ -0,0 +1,32 @@ +export type Tab = 'results' | 'grid' | 'strategy' | 'positions' | 'datasets' + +const TABS: { id: Tab; label: string }[] = [ + { id: 'results', label: 'Results' }, + { id: 'grid', label: 'Grid' }, + { id: 'strategy', label: 'Strategy' }, + { id: 'positions', label: 'Positions' }, + { id: 'datasets', label: 'Datasets' }, +] + +interface Props { + active: Tab + onChange: (tab: Tab) => void +} + +export function TabBar({ active, onChange }: Props) { + return ( +
+ {TABS.map((t) => ( + + ))} +
+ ) +} diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index 63a4f4f..59f7fb9 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -6,6 +6,10 @@ import { RaceHubHeader } from '../components/RaceHubHeader' import { DatasetStrip } from '../components/DatasetStrip' import { ClassificationTable } from '../components/ClassificationTable' import { StartingGridTable } from '../components/StartingGridTable' +import { TabBar, type Tab } from '../components/TabBar' +import { DatasetStatusView } from '../components/DatasetStatusView' +import { StrategyView } from '../components/StrategyView' +import { PositionEvolutionView } from '../components/PositionEvolutionView' interface Props { sessionKey: number @@ -14,6 +18,7 @@ interface Props { export function RaceHubPage({ sessionKey }: Props) { const navigate = useNavigate() const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '') + const [activeTab, setActiveTab] = useState('results') const { data, isLoading, isError, error } = useQuery({ queryKey: ['race-hub', sessionKey], @@ -85,25 +90,65 @@ export function RaceHubPage({ sessionKey }: Props) { -
-
- Final Classification - {data.results.length > 0 && ( - {data.results.length} drivers - )} -
- -
+ -
-
- Starting Grid - {data.starting_grid.length > 0 && ( - {data.starting_grid.length} positions - )} + {activeTab === 'results' && ( +
+
+ Final Classification + {data.results.length > 0 && ( + {data.results.length} drivers + )} +
+
- -
+ )} + + {activeTab === 'grid' && ( +
+
+ Starting Grid + {data.starting_grid.length > 0 && ( + {data.starting_grid.length} positions + )} +
+ +
+ )} + + {activeTab === 'strategy' && ( +
+
+ Race Strategy +
+ +
+ )} + + {activeTab === 'positions' && ( +
+
+ Position Evolution +
+ +
+ )} + + {activeTab === 'datasets' && ( +
+
+ Dataset Status +
+ +
+ )} )}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 739d7f6..14a1bc5 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -363,6 +363,65 @@ a { color: inherit; text-decoration: none; } color: var(--text-3); } +/* ── Tab bar ── */ +.tab-bar { + display: flex; + border-bottom: 1px solid var(--border); + margin-bottom: var(--s5); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.tab-bar::-webkit-scrollbar { display: none; } + +.tab-btn { + padding: 8px 14px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + white-space: nowrap; + transition: color 0.1s; + margin-bottom: -1px; + flex-shrink: 0; +} +.tab-btn:hover { color: var(--text-2); } +.tab-btn.active { color: var(--text); border-bottom-color: var(--red); } + +/* ── Dataset status view ── */ +.ds-legend { + display: flex; + align-items: center; + gap: var(--s5); + margin-bottom: var(--s5); + font-size: 12px; + color: var(--text-2); +} +.ds-legend code { + font-family: var(--f-mono); + font-size: 11px; + color: var(--text-3); +} + +/* ── Strategy / position views ── */ +.analysis-notice { + padding: var(--s4) var(--s5); + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border-2); + font-size: 12px; + color: var(--text-2); + margin-bottom: var(--s5); + max-width: 560px; +} +.analysis-notice strong { color: var(--text); } +.analysis-notice code { font-family: var(--f-mono); font-size: 11px; color: var(--text-3); } + /* ── Mobile ── */ @media (max-width: 640px) { .page { padding: var(--s3); } diff --git a/frontend/src/test/DatasetStatusView.test.tsx b/frontend/src/test/DatasetStatusView.test.tsx new file mode 100644 index 0000000..dc2562a --- /dev/null +++ b/frontend/src/test/DatasetStatusView.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { DatasetStatusView } from '../components/DatasetStatusView' +import type { DatasetInfo } from '../types' + +const allAvailable: Record = { + meeting: { status: 'available', source: 'local', count: 1 }, + session: { status: 'available', source: 'local', count: 1 }, + drivers: { status: 'available', source: 'local', count: 20 }, + results: { status: 'available', source: 'local', count: 20 }, + starting_grid: { status: 'available', source: 'local', count: 20 }, +} + +const partial: Record = { + meeting: { status: 'available', source: 'local', count: 1 }, + session: { status: 'available', source: 'local', count: 1 }, + drivers: { status: 'missing', source: 'none' }, + results: { status: 'missing', source: 'none' }, + starting_grid: { status: 'missing', source: 'none' }, +} + +describe('DatasetStatusView', () => { + it('shows 5/5 when all available', () => { + render() + expect(screen.getByText(/5\/5/)).toBeInTheDocument() + }) + + it('shows local badges for available datasets', () => { + render() + const localBadges = screen.getAllByText('Local') + expect(localBadges).toHaveLength(5) + }) + + it('shows missing badges for missing datasets', () => { + render() + const missingBadges = screen.getAllByText('Missing') + expect(missingBadges).toHaveLength(3) + }) + + it('shows the ingest command when data is missing', () => { + render() + expect(screen.getByText(/ingest-session/i)).toBeInTheDocument() + }) + + it('does not show ingest command when all available', () => { + render() + expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument() + }) + + it('shows record counts', () => { + render() + const twenties = screen.getAllByText('20') + expect(twenties.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/test/TabBar.test.tsx b/frontend/src/test/TabBar.test.tsx new file mode 100644 index 0000000..25a5142 --- /dev/null +++ b/frontend/src/test/TabBar.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { TabBar } from '../components/TabBar' + +describe('TabBar', () => { + it('renders all 5 tabs', () => { + render( {}} />) + expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument() + }) + + it('marks the active tab with aria-selected', () => { + render( {}} />) + expect(screen.getByRole('tab', { name: 'Grid' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Results' })).toHaveAttribute('aria-selected', 'false') + }) + + it('applies active class only to the active tab', () => { + render( {}} />) + const strategy = screen.getByRole('tab', { name: 'Strategy' }) + const results = screen.getByRole('tab', { name: 'Results' }) + expect(strategy.className).toContain('active') + expect(results.className).not.toContain('active') + }) + + it('calls onChange with the correct tab id when clicked', () => { + const onChange = vi.fn() + render() + fireEvent.click(screen.getByRole('tab', { name: 'Datasets' })) + expect(onChange).toHaveBeenCalledWith('datasets') + }) +})