mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add local data navigation UI
This commit is contained in:
40
documentations/refactor/19-phase-11-weekend-ingestion.md
Normal file
40
documentations/refactor/19-phase-11-weekend-ingestion.md
Normal file
@@ -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.
|
||||||
@@ -71,8 +71,10 @@ not implementation tickets yet.
|
|||||||
raw session keys.
|
raw session keys.
|
||||||
- [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for
|
- [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for
|
||||||
adding local-first season/weekend navigation around Race Hub.
|
adding local-first season/weekend navigation around Race Hub.
|
||||||
- [Cursor Phase 10 Prompt](cursor-phase-10-navigation-ui-prompt.md): current
|
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
|
||||||
handoff prompt for the next frontend phase.
|
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
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -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=<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.
|
|
||||||
@@ -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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RaceHub } from './types'
|
import type { Meeting, RaceHub, Weekend } from './types'
|
||||||
|
|
||||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
||||||
@@ -7,3 +7,29 @@ export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
|||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSeasons(): Promise<number[]> {
|
||||||
|
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<Meeting[]> {
|
||||||
|
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<Weekend> {
|
||||||
|
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|||||||
305
frontend/src/components/LocalDataNavigator.tsx
Normal file
305
frontend/src/components/LocalDataNavigator.tsx
Normal file
@@ -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<string, DatasetInfo>): { 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, DatasetInfo>): string {
|
||||||
|
const { available, total } = countRaceHubDatasets(datasets)
|
||||||
|
return `${available}/${total}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceBadge(source: WeekendSession['source']) {
|
||||||
|
switch (source) {
|
||||||
|
case 'local':
|
||||||
|
return <span className="badge badge-local">Local</span>
|
||||||
|
case 'partial':
|
||||||
|
return <span className="badge badge-partial">Partial</span>
|
||||||
|
default:
|
||||||
|
return <span className="badge badge-none">None</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number | null>(null)
|
||||||
|
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(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 <div className="nav-panel loading-state">loading local seasons…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="nav-panel error-box">
|
||||||
|
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = seasonsQuery.data ?? []
|
||||||
|
|
||||||
|
if (seasons.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="nav-panel" data-testid="local-nav-empty">
|
||||||
|
<div className="nav-panel-title">Local Data</div>
|
||||||
|
<div className="empty-state" style={{ padding: 'var(--s5) 0' }}>
|
||||||
|
<div className="empty-state-title">No ingested seasons yet</div>
|
||||||
|
<div className="empty-state-desc">
|
||||||
|
Ingest a session with <code>box-box --ingest-session <key></code>, then browse
|
||||||
|
here or enter a session key below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nav-panel" data-testid="local-nav">
|
||||||
|
<div className="nav-panel-head">
|
||||||
|
<span className="nav-panel-title">Local Data</span>
|
||||||
|
<div className="year-list" role="listbox" aria-label="Season">
|
||||||
|
{seasons.map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={year === selectedYear}
|
||||||
|
className={`year-btn ${year === selectedYear ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectYear(year)}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meetingsQuery.isLoading && (
|
||||||
|
<div className="nav-section-meta">loading meetings…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meetingsQuery.isError && (
|
||||||
|
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||||
|
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||||
|
<div className="nav-section-meta">No meetings ingested for {selectedYear}.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="nav-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Meetings</span>
|
||||||
|
<span className="sec-meta">{meetings.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scroll-x">
|
||||||
|
<table className="data-table nav-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Grand Prix</th>
|
||||||
|
<th className="hide-mobile">Country</th>
|
||||||
|
<th className="hide-mobile">Dates</th>
|
||||||
|
<th className="r">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{meetings.map((meeting) => {
|
||||||
|
const selected = meeting.meeting_key === selectedMeetingKey
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={meeting.meeting_key}
|
||||||
|
className={selected ? 'nav-row-selected' : ''}
|
||||||
|
data-testid={`meeting-row-${meeting.meeting_key}`}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
|
||||||
|
{meeting.circuit_short_name && meeting.circuit_short_name !== meeting.meeting_name && (
|
||||||
|
<span className="nav-sub">{meeting.circuit_short_name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{meeting.country_code || meeting.country_name}
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{formatMeetingDates(meeting)}
|
||||||
|
</td>
|
||||||
|
<td className="r">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-action-btn ${selected ? 'active' : ''}`}
|
||||||
|
aria-expanded={selected}
|
||||||
|
onClick={() => handleSelectMeeting(meeting.meeting_key)}
|
||||||
|
>
|
||||||
|
{selected ? 'Hide' : 'Sessions'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMeetingKey != null && weekendQuery.isLoading && (
|
||||||
|
<div className="nav-section-meta">loading sessions…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMeetingKey != null && weekendQuery.isError && (
|
||||||
|
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
|
||||||
|
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{weekend && (
|
||||||
|
<div className="nav-section" data-testid="weekend-sessions">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">{weekend.meeting.meeting_name} Sessions</span>
|
||||||
|
<span className="sec-meta">{weekend.sessions.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekend.sessions.length === 0 ? (
|
||||||
|
<div className="nav-section-meta">No sessions stored for this meeting.</div>
|
||||||
|
) : (
|
||||||
|
<div className="scroll-x">
|
||||||
|
<table className="data-table nav-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th className="hide-mobile">Type</th>
|
||||||
|
<th>Coverage</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th className="r">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{weekend.sessions.map(({ session, source, datasets }) => {
|
||||||
|
const coverage = formatCoverageHint(datasets)
|
||||||
|
const isDefault = session.session_key === weekend.default_session_key
|
||||||
|
return (
|
||||||
|
<tr key={session.session_key} data-testid={`session-row-${session.session_key}`}>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
|
||||||
|
{isDefault && <span className="nav-sub">default</span>}
|
||||||
|
<span className="nav-sub mono">{session.session_key}</span>
|
||||||
|
</td>
|
||||||
|
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{session.session_type}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{coverage}
|
||||||
|
</span>
|
||||||
|
<SessionCoverageDots datasets={datasets} />
|
||||||
|
</td>
|
||||||
|
<td>{sourceBadge(source)}</td>
|
||||||
|
<td className="r">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-action-btn nav-action-primary"
|
||||||
|
data-testid={`open-session-${session.session_key}`}
|
||||||
|
onClick={() => handleSelectSession(session.session_key)}
|
||||||
|
>
|
||||||
|
Race Hub
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCoverageDots({ datasets }: { datasets: Record<string, DatasetInfo> }) {
|
||||||
|
return (
|
||||||
|
<span className="coverage-dots" aria-hidden="true">
|
||||||
|
{RACE_HUB_DATASETS.map((key) => {
|
||||||
|
const available = datasets[key]?.status === 'available'
|
||||||
|
return <span key={key} className={`coverage-dot ${available ? 'on' : 'off'}`} />
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { fetchRaceHub } from '../api'
|
import { fetchRaceHub } from '../api'
|
||||||
|
import { LocalDataNavigator } from '../components/LocalDataNavigator'
|
||||||
import { RaceHubHeader } from '../components/RaceHubHeader'
|
import { RaceHubHeader } from '../components/RaceHubHeader'
|
||||||
import { DatasetStrip } from '../components/DatasetStrip'
|
import { DatasetStrip } from '../components/DatasetStrip'
|
||||||
import { ClassificationTable } from '../components/ClassificationTable'
|
import { ClassificationTable } from '../components/ClassificationTable'
|
||||||
@@ -20,6 +21,10 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
|
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('results')
|
const [activeTab, setActiveTab] = useState<Tab>('results')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputVal(sessionKey > 0 ? String(sessionKey) : '')
|
||||||
|
}, [sessionKey])
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ['race-hub', sessionKey],
|
queryKey: ['race-hub', sessionKey],
|
||||||
queryFn: () => fetchRaceHub(sessionKey),
|
queryFn: () => fetchRaceHub(sessionKey),
|
||||||
@@ -55,17 +60,8 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Prompt when no key entered */}
|
{/* Local data browser when no session loaded */}
|
||||||
{sessionKey === 0 && (
|
{sessionKey === 0 && <LocalDataNavigator />}
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-title">Enter a session key to load Race Hub data</div>
|
|
||||||
<div className="empty-state-desc">
|
|
||||||
Example: <code>9472</code> (Monaco GP 2025 Race)<br />
|
|
||||||
Ingest data first with{' '}
|
|
||||||
<code>box-box --ingest-session <key></code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{sessionKey > 0 && isLoading && (
|
{sessionKey > 0 && isLoading && (
|
||||||
|
|||||||
@@ -83,6 +83,123 @@ a { color: inherit; text-decoration: none; }
|
|||||||
padding: var(--s5) var(--s6);
|
padding: var(--s5) var(--s6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Local data navigator ── */
|
||||||
|
.nav-panel {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding-bottom: var(--s5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s4);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.year-btn:hover { color: var(--text); border-color: var(--text-3); }
|
||||||
|
.year-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--red);
|
||||||
|
background: rgba(225, 6, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section { margin-bottom: var(--s5); }
|
||||||
|
.nav-section:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.nav-section-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-table td { white-space: normal; }
|
||||||
|
|
||||||
|
.nav-row-selected { background: var(--surface-h); }
|
||||||
|
|
||||||
|
.nav-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-action-btn:hover { color: var(--text); border-color: var(--text-3); }
|
||||||
|
.nav-action-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
.nav-action-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: #c50500;
|
||||||
|
border-color: #c50500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: var(--s3);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-3);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.coverage-dot.on {
|
||||||
|
background: var(--green);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Session input bar ── */
|
/* ── Session input bar ── */
|
||||||
.session-bar {
|
.session-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
151
frontend/src/test/LocalDataNavigator.test.tsx
Normal file
151
frontend/src/test/LocalDataNavigator.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { LocalDataNavigator, countRaceHubDatasets, formatCoverageHint } from '../components/LocalDataNavigator'
|
||||||
|
import type { DatasetInfo, Meeting, Weekend } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchLocalMeetings: vi.fn(),
|
||||||
|
fetchWeekend: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
|
||||||
|
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||||
|
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||||
|
|
||||||
|
const meeting: Meeting = {
|
||||||
|
meeting_key: 1229,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T00:00:00+00:00',
|
||||||
|
year: 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDatasets: 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 },
|
||||||
|
stints: { status: 'available', source: 'local', count: 2 },
|
||||||
|
pit_stops: { status: 'available', source: 'local', count: 1 },
|
||||||
|
positions: { status: 'available', source: 'local', count: 3 },
|
||||||
|
race_control: { status: 'available', source: 'local', count: 1 },
|
||||||
|
weather: { status: 'available', source: 'local', count: 1 },
|
||||||
|
laps: { status: 'available', source: 'local', count: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekend: Weekend = {
|
||||||
|
source: 'local',
|
||||||
|
meeting_key: 1229,
|
||||||
|
meeting,
|
||||||
|
default_session_key: 9472,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
session: {
|
||||||
|
session_key: 9472,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1229,
|
||||||
|
date_start: '2025-05-25T13:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
source: 'local',
|
||||||
|
datasets: fullDatasets,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithProviders(onSelectSession?: (sessionKey: number) => void) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LocalDataNavigator onSelectSession={onSelectSession} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => null,
|
||||||
|
})
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('navigation helpers', () => {
|
||||||
|
it('counts available Race Hub datasets', () => {
|
||||||
|
expect(countRaceHubDatasets(fullDatasets)).toEqual({ available: 11, total: 11 })
|
||||||
|
expect(countRaceHubDatasets({ meeting: { status: 'available', source: 'local' } })).toEqual({
|
||||||
|
available: 1,
|
||||||
|
total: 11,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats coverage hint', () => {
|
||||||
|
expect(formatCoverageHint(fullDatasets)).toBe('11/11')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LocalDataNavigator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no seasons exist', async () => {
|
||||||
|
mockFetchSeasons.mockResolvedValue([])
|
||||||
|
|
||||||
|
renderWithProviders()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('local-nav-empty')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/No ingested seasons yet/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads meetings for the first season by default', async () => {
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
|
|
||||||
|
renderWithProviders()
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Monaco')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads weekend sessions and calls onSelectSession', async () => {
|
||||||
|
const onSelectSession = vi.fn()
|
||||||
|
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
|
|
||||||
|
renderWithProviders(onSelectSession)
|
||||||
|
|
||||||
|
await screen.findByText('Monaco')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Sessions' }))
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('weekend-sessions')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('11/11')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('open-session-9472'))
|
||||||
|
expect(onSelectSession).toHaveBeenCalledWith(9472)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -155,3 +155,17 @@ export interface Lap {
|
|||||||
lap_duration: number | null
|
lap_duration: number | null
|
||||||
is_pit_out_lap: boolean
|
is_pit_out_lap: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WeekendSession {
|
||||||
|
session: Session
|
||||||
|
source: 'local' | 'partial' | 'none'
|
||||||
|
datasets: Record<string, DatasetInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Weekend {
|
||||||
|
source: 'local' | 'partial' | 'none'
|
||||||
|
meeting_key: number
|
||||||
|
meeting: Meeting
|
||||||
|
sessions: WeekendSession[]
|
||||||
|
default_session_key?: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,4 +43,18 @@ test.describe('Race Hub', () => {
|
|||||||
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
||||||
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('navigates to race hub via local data browser', async ({ page }) => {
|
||||||
|
await page.goto('/race-hub')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('local-nav')).toBeVisible()
|
||||||
|
await expect(page.getByText('Monaco')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sessions' }).click()
|
||||||
|
await expect(page.getByTestId('weekend-sessions')).toBeVisible()
|
||||||
|
await page.getByTestId('open-session-9472').click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||||
|
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user