mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Expand React race hub analytics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
69
frontend/src/components/DatasetStatusView.tsx
Normal file
69
frontend/src/components/DatasetStatusView.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/PositionEvolutionView.tsx
Normal file
80
frontend/src/components/PositionEvolutionView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/StrategyView.tsx
Normal file
66
frontend/src/components/StrategyView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/src/components/TabBar.tsx
Normal file
32
frontend/src/components/TabBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
55
frontend/src/test/DatasetStatusView.test.tsx
Normal file
55
frontend/src/test/DatasetStatusView.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
35
frontend/src/test/TabBar.test.tsx
Normal file
35
frontend/src/test/TabBar.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user