mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from 'react'
|
||
|
|
import { useQueries, useQuery } from '@tanstack/react-query'
|
||
|
|
import { Link } from '@tanstack/react-router'
|
||
|
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||
|
|
import {
|
||
|
|
countWeekendStats,
|
||
|
|
formatCoverageHint,
|
||
|
|
sessionIconClass,
|
||
|
|
sessionTypeAbbrev,
|
||
|
|
} from '../lib/coverage'
|
||
|
|
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
||
|
|
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
|
||
|
|
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
|
||
|
|
import type { Meeting, Weekend } from '../types'
|
||
|
|
|
||
|
|
function formatMeetingDate(meeting: Meeting): string {
|
||
|
|
const start = meeting.date_start?.slice(0, 10)
|
||
|
|
if (!start) return '—'
|
||
|
|
const d = new Date(start)
|
||
|
|
if (Number.isNaN(d.getTime())) return start
|
||
|
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DataLibraryPage() {
|
||
|
|
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 meetings = meetingsQuery.data ?? []
|
||
|
|
|
||
|
|
const weekendQueries = useQueries({
|
||
|
|
queries: meetings.map((meeting) => ({
|
||
|
|
queryKey: ['weekend', meeting.meeting_key],
|
||
|
|
queryFn: () => fetchWeekend(meeting.meeting_key),
|
||
|
|
enabled: meetings.length > 0,
|
||
|
|
staleTime: 60_000,
|
||
|
|
})),
|
||
|
|
})
|
||
|
|
|
||
|
|
const weekendsByKey = useMemo(() => {
|
||
|
|
const map = new Map<number, Weekend>()
|
||
|
|
meetings.forEach((meeting, i) => {
|
||
|
|
const data = weekendQueries[i]?.data
|
||
|
|
if (data) map.set(meeting.meeting_key, data)
|
||
|
|
})
|
||
|
|
return map
|
||
|
|
}, [meetings, weekendQueries])
|
||
|
|
|
||
|
|
const stats = countWeekendStats(weekendQueries.map((q) => q.data))
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (seasonsQuery.data?.length && selectedYear == null) {
|
||
|
|
setSelectedYear(seasonsQuery.data[0])
|
||
|
|
}
|
||
|
|
}, [seasonsQuery.data, selectedYear])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (meetings.length === 0) {
|
||
|
|
setSelectedMeetingKey(null)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (selectedMeetingKey == null || !meetings.some((m) => m.meeting_key === selectedMeetingKey)) {
|
||
|
|
setSelectedMeetingKey(meetings[0].meeting_key)
|
||
|
|
}
|
||
|
|
}, [meetings, selectedMeetingKey])
|
||
|
|
|
||
|
|
const selectedWeekend = selectedMeetingKey != null ? weekendsByKey.get(selectedMeetingKey) : undefined
|
||
|
|
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
||
|
|
|
||
|
|
if (seasonsQuery.isLoading) {
|
||
|
|
return <div className="page loading-state">loading local data library…</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
if (seasonsQuery.isError) {
|
||
|
|
return (
|
||
|
|
<div className="page 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="page" data-testid="data-library-empty">
|
||
|
|
<div className="dl-page-header">
|
||
|
|
<h1 className="dl-page-title">Data Library</h1>
|
||
|
|
</div>
|
||
|
|
<div className="empty-state">
|
||
|
|
<div className="empty-state-title">No ingested seasons yet</div>
|
||
|
|
<div className="empty-state-desc">
|
||
|
|
Ingest a season or session from the CLI, then return here to inspect coverage.
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="dl-cli-section">
|
||
|
|
<div className="sec-header">
|
||
|
|
<span className="sec-title">Get Started</span>
|
||
|
|
</div>
|
||
|
|
<CliCommands
|
||
|
|
commands={[
|
||
|
|
{ comment: '# Ingest a full season', cmd: 'box-box --ingest-year 2025' },
|
||
|
|
{ comment: '# Or a single session', cmd: 'box-box --ingest-session <session_key>' },
|
||
|
|
]}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="dl-page" data-testid="data-library">
|
||
|
|
<div className="dl-layout">
|
||
|
|
<aside className="dl-nav">
|
||
|
|
<div>
|
||
|
|
<div className="sec-header">
|
||
|
|
<span className="sec-title">Seasons</span>
|
||
|
|
</div>
|
||
|
|
<div className="season-list" role="listbox" aria-label="Season">
|
||
|
|
{seasons.map((year) => (
|
||
|
|
<button
|
||
|
|
key={year}
|
||
|
|
type="button"
|
||
|
|
role="option"
|
||
|
|
aria-selected={year === selectedYear}
|
||
|
|
className={`season-row ${year === selectedYear ? 'active' : ''}`}
|
||
|
|
onClick={() => {
|
||
|
|
setSelectedYear(year)
|
||
|
|
setSelectedMeetingKey(null)
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<span>{year}</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{selectedYear != null && meetings.length > 0 && (
|
||
|
|
<div className="dl-stats">
|
||
|
|
<div className="dl-stat">
|
||
|
|
<span className="dl-stat-label">Full</span>
|
||
|
|
<span className="dl-stat-val dl-stat-full">{stats.full}</span>
|
||
|
|
</div>
|
||
|
|
<div className="dl-stat">
|
||
|
|
<span className="dl-stat-label">Partial</span>
|
||
|
|
<span className="dl-stat-val dl-stat-partial">{stats.partial}</span>
|
||
|
|
</div>
|
||
|
|
<div className="dl-stat">
|
||
|
|
<span className="dl-stat-label">Missing</span>
|
||
|
|
<span className="dl-stat-val">{stats.missing}</span>
|
||
|
|
</div>
|
||
|
|
<div className="dl-stat">
|
||
|
|
<span className="dl-stat-label">Total</span>
|
||
|
|
<span className="dl-stat-val">{stats.total}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{selectedYear != null && (
|
||
|
|
<div className="dl-cli-section">
|
||
|
|
<div className="sec-header">
|
||
|
|
<span className="sec-title">Season Ingest</span>
|
||
|
|
</div>
|
||
|
|
<CliCommands commands={ingestYearCommands(selectedYear)} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</aside>
|
||
|
|
|
||
|
|
<div className="dl-content">
|
||
|
|
<div className="dl-content-header">
|
||
|
|
<span className="dl-content-title">{selectedYear} Season</span>
|
||
|
|
{meetings.length > 0 && (
|
||
|
|
<span className="dl-content-meta">
|
||
|
|
{meetings.length} meeting{meetings.length === 1 ? '' : 's'}
|
||
|
|
{!weekendsLoading && (
|
||
|
|
<>
|
||
|
|
{' '}
|
||
|
|
· {stats.full} complete · {stats.partial} partial
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{meetingsQuery.isLoading && (
|
||
|
|
<div className="loading-state">loading meetings…</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{meetingsQuery.isError && (
|
||
|
|
<div className="error-box">
|
||
|
|
{meetingsQuery.error instanceof Error
|
||
|
|
? meetingsQuery.error.message
|
||
|
|
: 'Failed to load meetings'}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||
|
|
<div className="missing-notice">
|
||
|
|
No meetings ingested for {selectedYear}. Run{' '}
|
||
|
|
<code>box-box --ingest-year {selectedYear}</code>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{meetings.length > 0 && (
|
||
|
|
<div className="dl-content-body">
|
||
|
|
<div className="dl-round-scroll">
|
||
|
|
<table className="data-table rounds-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th className="c" style={{ width: 36 }}>
|
||
|
|
#
|
||
|
|
</th>
|
||
|
|
<th>Weekend</th>
|
||
|
|
<th className="hide-mobile">Date</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th className="hide-mobile">Sessions</th>
|
||
|
|
<th className="hide-mobile">Coverage</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{meetings.map((meeting, index) => {
|
||
|
|
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||
|
|
const selected = meeting.meeting_key === selectedMeetingKey
|
||
|
|
const source = weekend?.source ?? 'none'
|
||
|
|
return (
|
||
|
|
<tr
|
||
|
|
key={meeting.meeting_key}
|
||
|
|
className={selected ? 'dl-row-selected' : ''}
|
||
|
|
data-testid={`dl-meeting-${meeting.meeting_key}`}
|
||
|
|
onClick={() => setSelectedMeetingKey(meeting.meeting_key)}
|
||
|
|
>
|
||
|
|
<td className="c mono" style={{ color: 'var(--text-3)' }}>
|
||
|
|
{index + 1}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
|
||
|
|
<span className="nav-sub mono">key {meeting.meeting_key}</span>
|
||
|
|
</td>
|
||
|
|
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
|
||
|
|
{formatMeetingDate(meeting)}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<SourceBadge source={source} label={weekendStatusLabel(source)} />
|
||
|
|
</td>
|
||
|
|
<td className="hide-mobile">
|
||
|
|
{weekend ? (
|
||
|
|
<div className="session-icons">
|
||
|
|
{weekend.sessions.map((entry) => (
|
||
|
|
<span
|
||
|
|
key={entry.session.session_key}
|
||
|
|
className={`session-icon ${sessionIconClass(entry)}`}
|
||
|
|
title={`${entry.session.session_name} (${formatCoverageHint(entry.datasets)})`}
|
||
|
|
>
|
||
|
|
{sessionTypeAbbrev(entry.session.session_type, entry.session.session_name)}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<span className="mono" style={{ color: 'var(--text-3)' }}>
|
||
|
|
…
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
|
||
|
|
{weekend && weekend.sessions.length > 0
|
||
|
|
? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full`
|
||
|
|
: '—'}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="dl-detail-wrap">
|
||
|
|
{weekendsLoading && selectedWeekend == null && (
|
||
|
|
<div className="loading-state">loading weekend details…</div>
|
||
|
|
)}
|
||
|
|
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
|
||
|
|
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
|
||
|
|
<div className="missing-notice">Could not load weekend details.</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="dl-footer-link">
|
||
|
|
<Link to="/race-hub" search={{}}>
|
||
|
|
Open Race Hub →
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|