mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Add data library UI
This commit is contained in:
65
frontend/src/components/CliCommands.tsx
Normal file
65
frontend/src/components/CliCommands.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Command {
|
||||
comment?: string
|
||||
cmd: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
export function CliCommands({ commands }: Props) {
|
||||
return (
|
||||
<div className="cli-block" data-testid="cli-commands">
|
||||
{commands.map(({ comment, cmd }, i) => (
|
||||
<div key={cmd} className="cli-entry">
|
||||
{comment && <div className="cli-comment">{comment}</div>}
|
||||
<CliCommandLine cmd={cmd} />
|
||||
{i < commands.length - 1 && <div className="cli-spacer" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CliCommandLine({ cmd }: { cmd: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// clipboard may be unavailable in tests
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cli-cmd-row">
|
||||
<code className="cli-cmd">{cmd}</code>
|
||||
<button type="button" className="cli-copy-btn" onClick={handleCopy} aria-label={`Copy ${cmd}`}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ingestYearCommands(year: number): Command[] {
|
||||
return [
|
||||
{ comment: '# Ingest all meetings for a season', cmd: `box-box --ingest-year ${year}` },
|
||||
{ comment: '# Preview without downloading', cmd: `box-box --ingest-year ${year} --dry-run` },
|
||||
]
|
||||
}
|
||||
|
||||
export function ingestMeetingCommands(meetingKey: number): Command[] {
|
||||
return [
|
||||
{ comment: '# Full weekend ingest (all sessions)', cmd: `box-box --ingest-meeting ${meetingKey}` },
|
||||
{ comment: '# Preview without downloading', cmd: `box-box --ingest-meeting ${meetingKey} --dry-run` },
|
||||
]
|
||||
}
|
||||
|
||||
export function ingestSessionCommands(sessionKey: number): Command[] {
|
||||
return [{ comment: '# Race Hub datasets for one session', cmd: `box-box --ingest-session ${sessionKey}` }]
|
||||
}
|
||||
@@ -2,43 +2,10 @@ 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>
|
||||
}
|
||||
}
|
||||
import { formatCoverageHint } from '../lib/coverage'
|
||||
import { SourceBadge } from './SourceBadge'
|
||||
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||
import type { Meeting, WeekendSession } from '../types'
|
||||
|
||||
function formatMeetingDates(meeting: Meeting): string {
|
||||
const start = meeting.date_start?.slice(0, 10)
|
||||
@@ -47,6 +14,10 @@ function formatMeetingDates(meeting: Meeting): string {
|
||||
return start || end || '—'
|
||||
}
|
||||
|
||||
function sessionSourceBadge(source: WeekendSession['source']) {
|
||||
return <SourceBadge source={source} />
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelectSession?: (sessionKey: number) => void
|
||||
}
|
||||
@@ -269,7 +240,7 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
</span>
|
||||
<SessionCoverageDots datasets={datasets} />
|
||||
</td>
|
||||
<td>{sourceBadge(source)}</td>
|
||||
<td>{sessionSourceBadge(source)}</td>
|
||||
<td className="r">
|
||||
<button
|
||||
type="button"
|
||||
@@ -293,13 +264,5 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
// Re-export helpers used by tests
|
||||
export { countRaceHubDatasets, formatCoverageHint } from '../lib/coverage'
|
||||
|
||||
106
frontend/src/components/MeetingDetailPanel.tsx
Normal file
106
frontend/src/components/MeetingDetailPanel.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { RACE_HUB_DATASETS, formatCoverageHint } from '../lib/coverage'
|
||||
import { SourceBadge } from './SourceBadge'
|
||||
import { SessionCoverageDots } from './SessionCoverageDots'
|
||||
import {
|
||||
CliCommands,
|
||||
ingestMeetingCommands,
|
||||
ingestSessionCommands,
|
||||
} from './CliCommands'
|
||||
import type { Weekend, WeekendSession } from '../types'
|
||||
|
||||
interface Props {
|
||||
weekend: Weekend
|
||||
}
|
||||
|
||||
export function MeetingDetailPanel({ weekend }: Props) {
|
||||
const { meeting, sessions, source } = weekend
|
||||
|
||||
return (
|
||||
<div className="dl-detail" data-testid="meeting-detail">
|
||||
<div className="detail-header">
|
||||
<div className="detail-header-row">
|
||||
<span className="detail-title">{meeting.meeting_name}</span>
|
||||
<SourceBadge source={source} label={source === 'local' ? 'Full' : undefined} />
|
||||
</div>
|
||||
<div className="detail-meta">
|
||||
{meeting.country_name} · meeting_key {meeting.meeting_key}
|
||||
</div>
|
||||
<div className="detail-meta">
|
||||
{sessions.length} session{sessions.length === 1 ? '' : 's'} stored locally
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="missing-notice">
|
||||
No sessions ingested for this meeting. Run{' '}
|
||||
<code>box-box --ingest-meeting {meeting.meeting_key}</code>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((entry) => (
|
||||
<SessionDetailBlock key={entry.session.session_key} entry={entry} />
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="dl-cli-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Ingest Commands</span>
|
||||
</div>
|
||||
<CliCommands commands={ingestMeetingCommands(meeting.meeting_key)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDetailBlock({ entry }: { entry: WeekendSession }) {
|
||||
const { session, source, datasets } = entry
|
||||
const coverage = formatCoverageHint(datasets)
|
||||
|
||||
return (
|
||||
<div className="session-detail-row" data-testid={`session-detail-${session.session_key}`}>
|
||||
<div className="session-detail-head">
|
||||
<SourceBadge source={source} />
|
||||
<span>{session.session_name}</span>
|
||||
<span className="session-detail-key mono">{session.session_key}</span>
|
||||
<span className="session-detail-coverage mono">{coverage}</span>
|
||||
<SessionCoverageDots datasets={datasets} />
|
||||
</div>
|
||||
|
||||
<table className="data-table ds-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dataset</th>
|
||||
<th>Status</th>
|
||||
<th className="r">Records</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{RACE_HUB_DATASETS.map((key) => {
|
||||
const info = datasets[key]
|
||||
const available = info?.status === 'available'
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="mono" style={{ color: 'var(--text-2)' }}>
|
||||
{key}
|
||||
</td>
|
||||
<td>
|
||||
{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 className="session-cli">
|
||||
<CliCommands commands={ingestSessionCommands(session.session_key)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export function Nav() {
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/data-library" activeProps={{ className: 'active' }}>
|
||||
Data Library
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
|
||||
17
frontend/src/components/SessionCoverageDots.tsx
Normal file
17
frontend/src/components/SessionCoverageDots.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||
import type { DatasetInfo } from '../types'
|
||||
|
||||
interface Props {
|
||||
datasets: Record<string, DatasetInfo>
|
||||
}
|
||||
|
||||
export function SessionCoverageDots({ datasets }: Props) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/SourceBadge.tsx
Normal file
28
frontend/src/components/SourceBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
type Source = 'local' | 'partial' | 'none'
|
||||
|
||||
interface Props {
|
||||
source: Source
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function SourceBadge({ source, label }: Props) {
|
||||
switch (source) {
|
||||
case 'local':
|
||||
return <span className="badge badge-local">{label ?? 'Local'}</span>
|
||||
case 'partial':
|
||||
return <span className="badge badge-partial">{label ?? 'Partial'}</span>
|
||||
default:
|
||||
return <span className="badge badge-none">{label ?? 'None'}</span>
|
||||
}
|
||||
}
|
||||
|
||||
export function weekendStatusLabel(source: Source): string {
|
||||
switch (source) {
|
||||
case 'local':
|
||||
return 'Full'
|
||||
case 'partial':
|
||||
return 'Partial'
|
||||
default:
|
||||
return 'Missing'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user