Expand React race hub analytics

This commit is contained in:
2026-05-25 01:20:04 -04:00
parent 32d500f5af
commit 5470b0df38
13 changed files with 593 additions and 95 deletions

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
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 (
<div>
<div className="ds-legend">
<span>
{available}/{total} datasets available locally
</span>
{available < total && (
<span>
Re-run <code>box-box --ingest-session &lt;key&gt;</code> after backend
support exists for missing datasets.
</span>
)}
</div>
<table className="data-table" style={{ maxWidth: 480 }}>
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{entries.map(({ key, label, info }) => (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{label}
</td>
<td>
{info?.status === 'available' ? (
<span className="badge badge-local">Local</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -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 (
<div>
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> The backend does not
yet expose position samples in <code>/api/v1/race-hub</code>. Evolution
charts require per-driver position per lap.
</div>
{results.length > 0 && grid.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Grid Finish</span>
<span className="sec-meta">net positions gained/lost</span>
</div>
<table className="data-table" style={{ maxWidth: 420 }}>
<thead>
<tr>
<th>Driver</th>
<th className="hide-mobile">Team</th>
<th className="c">Grid</th>
<th className="c">Finish</th>
<th className="r">Δ</th>
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos =
grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
return (
<tr key={r.driver_number}>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{r.team_name}
</td>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
<td className="c mono">{r.position}</td>
<td className="r">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
</div>
)
}
// Placeholder for when position samples are available
return (
<div className="missing-notice">Position evolution chart: not yet implemented.</div>
)
}

View File

@@ -0,0 +1,66 @@
import type { EnrichedResult } from '../types'
interface Props {
results: EnrichedResult[]
hasStints: boolean
}
export function StrategyView({ results, hasStints }: Props) {
if (!hasStints) {
return (
<div>
<div className="analysis-notice">
<strong>Stints not available.</strong> The backend does not yet expose
tyre compound and stint ranges in <code>/api/v1/race-hub</code>. Strategy
charts require per-driver stints: compound, lap_start, lap_end.
</div>
{results.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Laps Completed</span>
<span className="sec-meta">from results hint at pit count</span>
</div>
<table className="data-table" style={{ maxWidth: 360 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="r">Laps</th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.driver_number}>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{r.position}
</td>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="r mono" style={{ color: r.number_of_laps > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.number_of_laps > 0 ? r.number_of_laps : '—'}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)
}
// Placeholder for when stints data is available
return (
<div className="missing-notice">Strategy chart: not yet implemented.</div>
)
}

View File

@@ -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 (
<div className="tab-bar" role="tablist">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
)
}

View File

@@ -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<Tab>('results')
const { data, isLoading, isError, error } = useQuery({
queryKey: ['race-hub', sessionKey],
@@ -85,6 +90,9 @@ export function RaceHubPage({ sessionKey }: Props) {
<DatasetStrip datasets={data.datasets} />
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'results' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Final Classification</span>
@@ -94,7 +102,9 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
<ClassificationTable results={data.results} grid={data.starting_grid} />
</div>
)}
{activeTab === 'grid' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Starting Grid</span>
@@ -104,6 +114,41 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
<StartingGridTable grid={data.starting_grid} />
</div>
)}
{activeTab === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'positions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Position Evolution</span>
</div>
<PositionEvolutionView
results={data.results}
grid={data.starting_grid}
hasPositions={data.datasets['positions']?.status === 'available'}
/>
</div>
)}
{activeTab === 'datasets' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Dataset Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</div>
)}
</>
)}
</div>

View File

@@ -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); }

View File

@@ -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<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
}
const partial: Record<string, DatasetInfo> = {
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(<DatasetStatusView datasets={allAvailable} />)
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
})
it('shows local badges for available datasets', () => {
render(<DatasetStatusView datasets={allAvailable} />)
const localBadges = screen.getAllByText('Local')
expect(localBadges).toHaveLength(5)
})
it('shows missing badges for missing datasets', () => {
render(<DatasetStatusView datasets={partial} />)
const missingBadges = screen.getAllByText('Missing')
expect(missingBadges).toHaveLength(3)
})
it('shows the ingest command when data is missing', () => {
render(<DatasetStatusView datasets={partial} />)
expect(screen.getByText(/ingest-session/i)).toBeInTheDocument()
})
it('does not show ingest command when all available', () => {
render(<DatasetStatusView datasets={allAvailable} />)
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
})
it('shows record counts', () => {
render(<DatasetStatusView datasets={allAvailable} />)
const twenties = screen.getAllByText('20')
expect(twenties.length).toBeGreaterThan(0)
})
})

View File

@@ -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(<TabBar active="results" onChange={() => {}} />)
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(<TabBar active="grid" onChange={() => {}} />)
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(<TabBar active="strategy" onChange={() => {}} />)
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(<TabBar active="results" onChange={onChange} />)
fireEvent.click(screen.getByRole('tab', { name: 'Datasets' }))
expect(onChange).toHaveBeenCalledWith('datasets')
})
})