diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d749b5f..de201cd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import type { Session, TrackOutline, Weekend, + WeekendContext, } from './types' export async function fetchRaceHub(sessionKey: number): Promise { @@ -114,6 +115,19 @@ export async function fetchWeekend(meetingKey: number): Promise { return res.json() } +// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint +// (backend story #72). The response is the authoritative WeekendContext shape and +// is used verbatim as the Weekend home's source of truth. Any HTTP error throws +// so the hook can surface an explicit error state; there is no client-side +// re-derivation of the contract. +export async function fetchWeekendContext(): Promise { + const res = await fetch('/api/v1/weekend-context') + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} + export async function fetchChampionshipHub(year?: number): Promise { const params = new URLSearchParams({ source: 'auto' }) if (year) params.set('year', year.toString()) diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 5542858..2946848 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -1,36 +1,66 @@ import { Link } from '@tanstack/react-router' +import { Compass, FileText, Home, Trophy } from 'lucide-react' +const PRIMARY = [ + { to: '/', label: 'Weekend', icon: Home, exact: true }, + { to: '/championship', label: 'Championship', icon: Trophy, exact: false }, + { to: '/briefing', label: 'Briefing', icon: FileText, exact: false }, + { to: '/explore', label: 'Explore', icon: Compass, exact: false }, +] as const + +/** + * Nav renders one primary navigation system per breakpoint: + * - Desktop/tablet: the top bar's `aria-label="Primary"` links. + * - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links + * are hidden via CSS so the two are never both active at once. + * + * Admin is an operator utility, deliberately outside every Primary landmark — it + * lives in a plain toolbar slot and never appears in the mobile bottom nav. + */ export function Nav() { return ( - + +
+ + Admin + +
+ + + + ) } diff --git a/frontend/src/components/weekend/BetweenRacesView.tsx b/frontend/src/components/weekend/BetweenRacesView.tsx new file mode 100644 index 0000000..f8316f8 --- /dev/null +++ b/frontend/src/components/weekend/BetweenRacesView.tsx @@ -0,0 +1,152 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight, Flag as FlagIcon } from 'lucide-react' +import type { + WeekendBriefingItem, + WeekendChampionshipImpact, + WeekendContext, + WeekendViewState, +} from '../../types' +import { + BriefingStrip, + ChampionshipImpactCard, + ChampionshipRoundStrip, + CountdownDisplay, + EventPodium, + Flag, +} from './shared' +import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext' +import { parseScheduleTime } from '../../lib/schedule' + +const EYEBROW: Record = { + between_weekends: 'Between races', + post_weekend: 'Post-weekend', + season_complete: 'Season complete', +} + +function formatSessionLine(name: string | undefined, start: string | undefined): string { + if (!name) return 'Schedule to be confirmed' + const date = start ? parseScheduleTime(start) : null + if (!date) return name + const when = date.toLocaleString('en-GB', { + weekday: 'long', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + return `${name} · ${when}` +} + +export function BetweenRacesView({ + context, + now, + view, + championship, + briefing, +}: { + context: WeekendContext + now: Date + view: WeekendViewState + championship?: WeekendChampionshipImpact + briefing: WeekendBriefingItem[] +}) { + const eyebrow = EYEBROW[view] ?? 'Between races' + + const previous = context.previous_completed_session + const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting) + const nextMeeting = meetingIdentity(context.next_meeting) + const nextSession = context.next_session?.session + const analysisKey = analysisSessionKey(context) + + return ( +
+
{eyebrow}
+ +
+ {previousMeeting && ( +
+
+
+ +

{previousMeeting.meeting_name}

+
+ + +
+
+ +
+ What decided it? + {analysisKey ? ( + + Explore Race Story
+
+
+ )} + + {nextMeeting ? ( +
+
+
+ +

{nextMeeting.meeting_name}

+
+ Next event +
+
+ +
+ Next session + + {formatSessionLine(nextSession?.session_name, nextSession?.date_start)} + +
+ + Prepare for {nextMeeting.short_name} +
+
+ ) : ( +
+
+

That's a wrap

+ Off-season +
+
+

+ The {context.season} calendar is complete. Explore the season's races or revisit the championship + battle while the next schedule is confirmed. +

+ + Explore the season
+
+ )} +
+ +
+ {championship && } + +
+ + {briefing.length > 0 && } +
+ ) +} diff --git a/frontend/src/components/weekend/BetweenSessionsView.tsx b/frontend/src/components/weekend/BetweenSessionsView.tsx new file mode 100644 index 0000000..d92635b --- /dev/null +++ b/frontend/src/components/weekend/BetweenSessionsView.tsx @@ -0,0 +1,83 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight } from 'lucide-react' +import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types' +import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared' +import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext' +import { parseScheduleTime } from '../../lib/schedule' + +function nextSessionWhen(start: string | undefined): string { + const date = start ? parseScheduleTime(start) : null + if (!date) return 'Time to be confirmed' + return date.toLocaleString('en-GB', { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +export function BetweenSessionsView({ + context, + now, + view, + briefing, +}: { + context: WeekendContext + now: Date + view: WeekendViewState + briefing: WeekendBriefingItem[] +}) { + const settling = view === 'session_settling' + const focus = meetingIdentity(context.focus_meeting) + const previous = context.previous_completed_session + const previousName = previous?.session.session_name ?? 'Last session' + const next = context.next_session?.session + const analysisKey = analysisSessionKey(context) + const nodes = railNodes(previous, context.active_session, context.next_session) + + return ( +
+
+ {settling ? 'Session settling' : 'Between sessions'} +
+ +
+

{focus?.meeting_name ?? 'Race weekend'}

+ {focus?.circuit_short_name &&

{focus.circuit_short_name}

} +
+ + + + {previous && ( +
+ + {previousName} · {settling ? 'Settling' : 'Complete'} + +

What happened in {previousName}

+ +
+ )} + + {next && ( +
+ Up next +

{next.session_name}

+ +

{nextSessionWhen(next.date_start)}

+ {analysisKey && ( + + View {previousName} recap
+ )} + + {briefing.length > 0 && } +
+ ) +} diff --git a/frontend/src/components/weekend/LiveHandoffView.tsx b/frontend/src/components/weekend/LiveHandoffView.tsx new file mode 100644 index 0000000..b34e9b8 --- /dev/null +++ b/frontend/src/components/weekend/LiveHandoffView.tsx @@ -0,0 +1,47 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight, Radio } from 'lucide-react' +import type { WeekendContext } from '../../types' +import { EventPodium, Flag, SessionRail, railNodes } from './shared' +import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext' + +export function LiveHandoffView({ context }: { context: WeekendContext }) { + const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting) + const active = context.active_session?.session + const previous = context.previous_completed_session + const previousName = previous?.session.session_name ?? 'Last session' + const analysisKey = analysisSessionKey(context) + const nodes = railNodes(previous, context.active_session, context.next_session) + + return ( +
+
Session live
+ +
+
+
+ +

{focus?.meeting_name ?? 'Live session'}

+
+
+ {focus?.circuit_short_name &&

{focus.circuit_short_name}

} +

+ {active?.session_name ? `${active.session_name} is on track now.` : 'A session is running now.'} +

+ +
+ + + + {previous && ( +
+ {previousName} · Complete +

What happened in {previousName}

+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/weekend/PreSessionView.tsx b/frontend/src/components/weekend/PreSessionView.tsx new file mode 100644 index 0000000..d295a2b --- /dev/null +++ b/frontend/src/components/weekend/PreSessionView.tsx @@ -0,0 +1,56 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight } from 'lucide-react' +import type { WeekendContext } from '../../types' +import { RacePreviewPage } from '../../pages/RacePreviewPage' +import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared' +import { meetingIdentity } from '../../lib/weekendContext' + +/** + * PreSessionView folds the race preview surface into the Weekend home so there is + * no separate Preview destination. It reuses the existing preview page content and + * frames it with the next-session countdown and compact season navigation. It also + * backs the /preview alias, so saved preview links resolve here instead of looping. + */ +export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) { + const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting) + const next = context.next_session?.session + const nextStart = next?.date_start ?? meeting?.date_start + const nodes = railNodes( + context.previous_completed_session, + context.active_session, + context.next_session, + ) + + return ( +
+
Pre-session
+ + {meeting && ( +
+
+ +

{meeting.meeting_name}

+
+
+ {next?.session_name ?? 'Next session'} + +
+ + Live timing
+ )} + + {nodes.length > 0 && } + +
+ +
+ + +
+ ) +} diff --git a/frontend/src/components/weekend/StatusViews.tsx b/frontend/src/components/weekend/StatusViews.tsx new file mode 100644 index 0000000..609af6c --- /dev/null +++ b/frontend/src/components/weekend/StatusViews.tsx @@ -0,0 +1,36 @@ +import { Link } from '@tanstack/react-router' + +export function WeekendLoading() { + return ( +
+ box-box · weekend +

Reading the weekend…

+
+ ) +} + +export function WeekendError({ message }: { message?: string }) { + return ( +
+ box-box · weekend +

Weekend unavailable

+

{message ?? 'Something went wrong loading the weekend context.'}

+
+ ) +} + +export function WeekendLimited({ message, season }: { message?: string; season?: number }) { + return ( +
+ box-box · weekend +

Nothing to show yet

+

+ {message ?? `No weekend data for the ${season || 'current'} season is available right now.`} +

+
+ Live timing + Explore +
+
+ ) +} diff --git a/frontend/src/components/weekend/shared.tsx b/frontend/src/components/weekend/shared.tsx new file mode 100644 index 0000000..19a9bfb --- /dev/null +++ b/frontend/src/components/weekend/shared.tsx @@ -0,0 +1,277 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { fetchRaceHub } from '../../api' +import { countryFlag } from '../../lib/gpIdentity' +import { parseScheduleTime } from '../../lib/schedule' +import { podiumFromResults } from '../../lib/weekendContext' +import { teamColor } from '../../utils' +import type { + ContextSession, + WeekendChampionshipImpact, + WeekendBriefingItem, + WeekendPodiumEntry, +} from '../../types' + +export function Flag({ code, flag }: { code?: string; flag?: string }) { + const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' }) + if (!glyph) return null + return +} + +interface CountdownParts { + days: number + hours: number + minutes: number + seconds: number + reached: boolean +} + +export function countdownParts(target: string | undefined, now: Date): CountdownParts | null { + const date = target ? parseScheduleTime(target) : null + if (!date) return null + const diff = date.getTime() - now.getTime() + const clamped = Math.max(0, diff) + const totalSeconds = Math.floor(clamped / 1000) + return { + days: Math.floor(totalSeconds / 86400), + hours: Math.floor((totalSeconds % 86400) / 3600), + minutes: Math.floor((totalSeconds % 3600) / 60), + seconds: totalSeconds % 60, + reached: diff <= 0, + } +} + +export function CountdownDisplay({ + target, + now, + compact, +}: { + target: string | undefined + now: Date + compact?: boolean +}) { + const parts = countdownParts(target, now) + if (!parts) { + return ( + + Schedule TBC + + ) + } + if (parts.reached) { + return Starting now + } + if (compact || parts.days === 0) { + const hh = String(parts.days * 24 + parts.hours).padStart(2, '0') + return ( + + {hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')} + + ) + } + return ( + + {parts.days}D + {String(parts.hours).padStart(2, '0')}H + {String(parts.minutes).padStart(2, '0')}M + + ) +} + +/** + * EventPodium fetches and renders the podium for a completed analysis session. + * When no analysable session key is available (e.g. an archived result with no + * local analysis), it renders an explicit empty state rather than fetching. + */ +export function EventPodium({ sessionKey }: { sessionKey?: number }) { + const enabled = typeof sessionKey === 'number' && sessionKey > 0 + const query = useQuery({ + queryKey: ['race-hub', sessionKey, 'podium'], + queryFn: () => fetchRaceHub(sessionKey as number), + enabled, + staleTime: 60_000, + }) + + const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? []) + + if (!enabled) { + return ( +
+ Result not available yet. +
+ ) + } + + if (podium.length === 0) { + return ( +
+ {query.isLoading ? 'Loading result…' : 'Result not available yet.'} +
+ ) + } + + return ( +
    + {podium.map((p) => ( +
  1. + {p.position} + {p.name_acronym} + {p.gap} +
  2. + ))} +
+ ) +} + +export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) { + const leader = impact.leaders[0] + return ( +
+
+ Championship impact + Standings → +
+
    + {impact.leaders.map((d) => { + const gap = leader && d.position !== 1 ? leader.points - d.points : 0 + return ( +
  1. + {d.position} + {d.name_acronym} + {d.points} + {d.position === 1 ? '—' : `-${gap}`} +
  2. + ) + })} +
+ {impact.note &&

{impact.note}

} +
+ ) +} + +export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) { + if (items.length === 0) return null + return ( +
+
+ Since the chequered flag + All briefing → +
+ +
+ ) +} + +/** + * ChampionshipRoundStrip is a compact, progressively-disclosed round indicator. + * The canonical contract exposes only the current round and total, so the strip + * shows "Round N of M" and links out to Explore for the full calendar rather than + * reintroducing the whole calendar on the Weekend home. + */ +export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) { + if (round <= 0 || total <= 0) return null + const pct = Math.min(100, Math.round((round / total) * 100)) + return ( +
+
+
+ Season progress + + Full calendar → + +
+
+
+

+ Round {round} of {total} +

+
+
+ ) +} + +interface TimelineNode { + key: string + session_name: string + status: 'done' | 'live' | 'next' +} + +/** + * SessionRail renders the previous/active/next sessions the canonical context + * exposes as a compact three-node rail. It is intentionally derived only from the + * canonical refs — the contract does not enumerate a full weekend session list. + */ +export function SessionRail({ nodes }: { nodes: TimelineNode[] }) { + if (nodes.length === 0) return null + return ( +
    + {nodes.map((n) => ( +
  1. +
  2. + ))} +
+ ) +} + +/** Build the compact session rail from the canonical previous/active/next refs. */ +export function railNodes( + previous: ContextSession | undefined, + active: ContextSession | undefined, + next: ContextSession | undefined, +): TimelineNode[] { + const nodes: TimelineNode[] = [] + if (previous?.session.session_name) { + nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' }) + } + if (active?.session.session_name) { + nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' }) + } + if (next?.session.session_name) { + nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' }) + } + return nodes +} + +function shortSessionName(name: string): string { + const map: Record = { + 'Practice 1': 'FP1', + 'Practice 2': 'FP2', + 'Practice 3': 'FP3', + Qualifying: 'Quali', + 'Sprint Qualifying': 'SQ', + 'Sprint Shootout': 'SQ', + } + return map[name] ?? name +} + +function stateLabel(status: TimelineNode['status']): string { + switch (status) { + case 'done': + return 'Complete' + case 'live': + return 'Live' + default: + return 'Next' + } +} diff --git a/frontend/src/hooks/useWeekendContext.ts b/frontend/src/hooks/useWeekendContext.ts new file mode 100644 index 0000000..67bdabf --- /dev/null +++ b/frontend/src/hooks/useWeekendContext.ts @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api' +import { championshipImpact, briefingItems } from '../lib/weekendContext' +import type { + WeekendChampionshipImpact, + WeekendBriefingItem, + WeekendContext, +} from '../types' + +export type WeekendLoadState = 'loading' | 'error' | 'ready' + +export interface UseWeekendContextResult { + /** Canonical context, present only when loadState === 'ready'. */ + context: WeekendContext | null + loadState: WeekendLoadState + error?: Error + /** Supplementary championship movers (not part of the #72 contract). */ + championship?: WeekendChampionshipImpact + /** Supplementary briefing items (not part of the #72 contract). */ + briefing: WeekendBriefingItem[] + now: Date +} + +/** + * useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the + * single source of truth for the Weekend home. When the canonical read succeeds + * it layers on two pieces of supplementary data that the contract intentionally + * omits — championship movers and the paddock briefing — and nothing else. + * + * It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1 + * session / live-state queries: those would defeat the local-first canonical read + * model and can hit the rate-limited OpenF1 REST surface during active sessions. + * Supplementary queries are gated on a successful canonical read so a failing or + * pending endpoint issues no extra requests. + */ +export function useWeekendContext(): UseWeekendContextResult { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(timer) + }, []) + const nowDate = useMemo(() => new Date(now), [now]) + + const contextQuery = useQuery({ + queryKey: ['weekend-context'], + queryFn: fetchWeekendContext, + staleTime: 30_000, + }) + + const canonical = contextQuery.data ?? null + const canonicalReady = canonical != null + const season = canonical?.season + + // Supplementary data — only fetched once the canonical context has resolved, + // so a pending/failed canonical read never triggers a request fan-out. + const championshipQuery = useQuery({ + queryKey: ['championship-hub', season ?? 'current'], + queryFn: () => fetchChampionshipHub(season), + enabled: canonicalReady, + staleTime: 60_000, + }) + + const newsQuery = useQuery({ + queryKey: ['news', 6], + queryFn: () => fetchNews(6), + enabled: canonicalReady, + staleTime: 60_000, + }) + + const championship = useMemo( + () => championshipImpact(championshipQuery.data), + [championshipQuery.data], + ) + const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data]) + + let loadState: WeekendLoadState = 'loading' + if (contextQuery.isError) loadState = 'error' + else if (canonicalReady) loadState = 'ready' + + return { + context: canonical, + loadState, + error: contextQuery.error instanceof Error ? contextQuery.error : undefined, + championship, + briefing, + now: nowDate, + } +} diff --git a/frontend/src/lib/weekendContext.ts b/frontend/src/lib/weekendContext.ts new file mode 100644 index 0000000..689074f --- /dev/null +++ b/frontend/src/lib/weekendContext.ts @@ -0,0 +1,181 @@ +import type { + ChampionshipHub, + ContextSession, + EnrichedResult, + Meeting, + NewsItem, + Session, + WeekendBriefingItem, + WeekendChampionshipImpact, + WeekendChampionshipMover, + WeekendContext, + WeekendPodiumEntry, + WeekendViewState, +} from '../types' + +/** + * resolveViewState maps the canonical `temporal_state` onto the rendered Weekend + * view. It is a total function: every valid canonical `temporal_state` resolves + * to a concrete view, so a well-formed payload never falls through to a + * limited/empty placeholder. Loading and error are hook-level states that are + * not part of the #72 contract. + */ +export function resolveViewState(context: WeekendContext): WeekendViewState { + switch (context.temporal_state) { + case 'no_season': + return 'no_season' + case 'between_weekends': + return 'between_weekends' + case 'pre_session': + return 'pre_session' + case 'session_live': + return 'session_live' + case 'session_settling': + return 'session_settling' + case 'between_sessions': + return 'between_sessions' + case 'post_weekend': + return 'post_weekend' + case 'season_complete': + return 'season_complete' + default: + // Unknown/absent temporal_state is an invalid payload — treat as no_season + // rather than inventing a state. The hook still shows an explicit surface. + return 'no_season' + } +} + +export interface MeetingIdentity { + meeting_key: number + meeting_name: string + short_name: string + country_code: string + country_flag: string + circuit_short_name: string + circuit_key?: number + date_start?: string + date_end?: string +} + +/** Presentation identity for a meeting, tolerant of partial/absent fields. */ +export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined { + if (!meeting) return undefined + return { + meeting_key: meeting.meeting_key, + meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix', + short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''), + country_code: meeting.country_code, + country_flag: meeting.country_flag, + circuit_short_name: meeting.circuit_short_name || meeting.location || '', + circuit_key: meeting.circuit_key, + date_start: meeting.date_start || undefined, + date_end: meeting.date_end || undefined, + } +} + +/** Session identity paired with its resolved meeting for a canonical ContextSession. */ +export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined { + return ref?.meeting +} + +export function sessionOf(ref: ContextSession | undefined): Session | undefined { + return ref?.session +} + +/** + * analysisSessionKey resolves the session a user should open to read analysis for + * a completed event. It prefers the canonical default analysis session, falling + * back to the previous completed session. Returns undefined when nothing analysable + * is available (e.g. an archived result with no local analysis). + */ +export function analysisSessionKey(context: WeekendContext): number | undefined { + const key = + context.default_analysis_session?.session.session_key ?? + context.previous_completed_session?.session.session_key + return key && key > 0 ? key : undefined +} + +/** True when a ContextSession has local analysis worth linking to. */ +export function hasLocalAnalysis(ref: ContextSession | undefined): boolean { + const status = ref?.availability.local_analysis + return status === 'complete' || status === 'partial' +} + +/** Countdown target for a session reference — its scheduled start, if known. */ +export function sessionStart(ref: ContextSession | undefined): string | undefined { + const start = ref?.session.date_start + return start ? start : undefined +} + +export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] { + return [...results] + .filter((r) => r.position >= 1) + .sort((a, b) => a.position - b.position) + .slice(0, 3) + .map((r) => ({ + position: r.position, + driver_number: r.driver_number, + name_acronym: r.name_acronym, + team_name: r.team_name, + team_colour: r.team_colour, + gap: formatResultGap(r), + })) +} + +function formatResultGap(r: EnrichedResult): string { + if (r.dnf) return 'DNF' + if (r.dns) return 'DNS' + if (r.dsq) return 'DSQ' + if (r.position === 1) { + if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration) + return 'Winner' + } + const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader + if (typeof gap === 'number') return `+${gap.toFixed(3)}` + if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}` + return '—' +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}` + return `${m}:${s.toFixed(3).padStart(6, '0')}` +} + +/** + * championshipImpact derives the top-of-standings movers from the championship + * hub. This is supplementary data layered onto the canonical context, not part of + * the #72 contract. + */ +export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined { + if (!hub || hub.drivers.length === 0) return undefined + const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => { + const cumulative = d.cumulative ?? [] + const delta = + cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined + return { + position: d.position, + driver_number: d.driver_number, + name_acronym: d.name_acronym, + team_colour: d.team_colour, + points: d.points, + delta, + } + }) + const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined + return { leaders, note } +} + +/** At most three briefing items, mapped from the news feed. */ +export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] { + return news.slice(0, 3).map((item) => ({ + category: item.category, + title: item.title, + url: item.url, + source: item.source, + published_at: item.published_at, + image_url: item.og_image_url, + })) +} diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx deleted file mode 100644 index 29c7493..0000000 --- a/frontend/src/pages/CommandCenterPage.tsx +++ /dev/null @@ -1,451 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { useQueries, useQuery } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import { - fetchChampionshipHub, - fetchLiveState, - fetchLocalMeetings, - fetchRaceHub, - fetchSeasonMeetings, - fetchSeasons, - fetchSessions, - fetchWeekend, -} from '../api' -import { CommandCenterHero } from '../components/CommandCenterHero' -import { PaddockBriefing } from '../components/PaddockBriefing' -import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' -import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' -import { classifySessionStatus, heroState } from '../lib/hero' -import { - currentAndNextSession, - focusMeetingKind, - formatSessionScheduleTime, - meetingHasStarted, - mostRecentPastMeeting, - nextUpcomingMeeting, - pickFocusMeeting, - sortSessionsByStart, -} from '../lib/schedule' -import type { Meeting, Session, Weekend, WeekendSession } from '../types' -import { Trophy } from 'lucide-react' - -const missingDatasets = Object.fromEntries( - RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]), -) as WeekendSession['datasets'] - -function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { - if (meeting.meeting_key === focusKey) return 'focus' - if (meetingHasStarted(meeting, now)) return 'past' - return 'future' -} - -function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined { - if (!weekend) return undefined - const local = weekend.sessions.filter((s) => s.source === 'local') - const partial = weekend.sessions.filter((s) => s.source === 'partial') - const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions - const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race')) - if (race) return race - const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying')) - if (qual) return qual - return pool[0] -} - -export function CommandCenterPage() { - const [now, setNow] = useState(() => Date.now()) - - const seasonsQuery = useQuery({ - queryKey: ['seasons'], - queryFn: fetchSeasons, - }) - - const latestSeason = seasonsQuery.data?.[0] ?? null - - const meetingsQuery = useQuery({ - queryKey: ['meetings', latestSeason], - queryFn: () => fetchLocalMeetings(latestSeason!), - enabled: latestSeason != null, - }) - - const seasonMeetingsQuery = useQuery({ - queryKey: ['season-meetings', latestSeason], - queryFn: () => fetchSeasonMeetings(latestSeason!), - enabled: latestSeason != null, - }) - - const localMeetings = meetingsQuery.data ?? [] - const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings - const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings - - const weekendQueries = useQueries({ - queries: localMeetings.map((meeting) => ({ - queryKey: ['weekend', meeting.meeting_key], - queryFn: () => fetchWeekend(meeting.meeting_key), - enabled: localMeetings.length > 0, - staleTime: 60_000, - })), - }) - - const liveQuery = useQuery({ - queryKey: ['live-state'], - queryFn: fetchLiveState, - staleTime: 5_000, - }) - - const championshipQuery = useQuery({ - queryKey: ['championship-hub', latestSeason], - queryFn: () => fetchChampionshipHub(latestSeason!), - enabled: latestSeason != null, - }) - const champHub = championshipQuery.data - - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(timer) - }, []) - - const nowDate = useMemo(() => new Date(now), [now]) - - const weekendsByKey = useMemo(() => { - const map = new Map() - localMeetings.forEach((meeting, i) => { - const data = weekendQueries[i]?.data - if (data) map.set(meeting.meeting_key, data) - }) - return map - }, [localMeetings, weekendQueries]) - - const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries]) - const meetingStats = countWeekendStats(weekendList) - const focusMeeting = pickFocusMeeting(focusMeetings, nowDate) - const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined - const openF1SessionsQuery = useQuery({ - queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'], - queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'), - enabled: focusMeeting != null && focusWeekend == null, - staleTime: 60_000, - }) - const openF1WeekendSessions: WeekendSession[] = useMemo( - () => - (openF1SessionsQuery.data ?? []).map((session) => ({ - session, - source: 'none', - datasets: missingDatasets, - })), - [openF1SessionsQuery.data], - ) - const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions - const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null - const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session)) - const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate) - - const analysisSession = pickAnalysisSession(focusWeekend) - const actionSession = - analysisSession?.session ?? - currentSession ?? - nextSession ?? - focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ?? - focusWeekendSessions[0]?.session - const analysisSessionKey = actionSession?.session_key - - const liveActive = liveQuery.data?.is_live === true - const heroStateKind = heroState({ - now: nowDate, - liveActive, - currentSession, - focusKind, - }) - - const lastPastMeeting = useMemo( - () => mostRecentPastMeeting(focusMeetings, nowDate), - [focusMeetings, nowDate], - ) - const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined - const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend) - const lastRaceSessionKey = lastRaceAnalysis?.session.session_key - - const lastRaceHubQuery = useQuery({ - queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'], - queryFn: () => fetchRaceHub(lastRaceSessionKey!), - enabled: lastRaceSessionKey != null && heroStateKind === 'between', - staleTime: 60_000, - }) - - const nextMeetingForHero = useMemo(() => { - if (heroStateKind !== 'between') return null - return nextUpcomingMeeting(focusMeetings, nowDate) - }, [heroStateKind, focusMeetings, nowDate]) - - const lastRacePodium = lastRaceHubQuery.data?.results ?? [] - const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? '' - - if (seasonsQuery.isLoading) { - return
loading command center…
- } - - if (seasonsQuery.isError) { - return ( -
- {seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'} -
- ) - } - - const seasons = seasonsQuery.data ?? [] - - if (seasons.length === 0) { - return ( -
-
- box-box · command center -

No local data yet

-

- Ingest a race weekend from the CLI to populate this screen with live status, next-session - countdowns, and analysis links. -

-
-
- - Live Timing - Standby - - - Admin · Data Health - Ingestion guidance - -
-
- ) - } - - const accent = countryAccent(focusMeeting ?? null) - const accentStyle = { '--gp-accent': accent } as React.CSSProperties - - return ( -
-
- box-box · command center - - {latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full - - - - {liveActive ? 'Live session active' : 'No live session'} - -
- - {!focusMeeting && ( -
- No meetings ingested for {latestSeason}. Run{' '} - box-box --ingest-year {latestSeason} -
- )} - - {focusMeeting && focusKind && ( - - )} - -
-
- - -
- {champHub && ( -
-
- Championship Leaders - Full Standings → -
-
Drivers
-
- {champHub.drivers.slice(0, 3).map((d) => ( -
-
-
- {d.name_acronym} -
-
- -
-
- - {d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`} - - {d.points} -
-
- ))} -
-
Constructors
-
- {champHub.teams.slice(0, 3).map((t, i) => ( -
-
-
- {t.team_name} -
-
- {t.wins} -
-
- - {i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`} - - {t.points} -
-
- ))} -
-
- )} - - {focusWeekendSessions.length > 0 && ( -
-
- Weekend Schedule - {focusWeekendSessions.length} sessions -
-
- {focusWeekendSessions.map(({ session, source, datasets }) => { - const status = classifySessionStatus(session, nowDate) - const isNext = nextSession?.session_key === session.session_key - const isCurrent = currentSession?.session_key === session.session_key - const isLive = isCurrent && liveActive - return ( - -
- - {sessionTypeAbbrev(session.session_type, session.session_name)} - - - {isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'} - -
-
{session.session_name}
-
{formatSessionScheduleTime(session.date_start)}
-
-
- - ) - })} -
-
- )} - - -
-
-
- ) -} - -function FormSparkline({ form, color }: { form: number[], color: string }) { - if (!form || form.length === 0) return null - const max = Math.max(...form, 26) // 26 is standard max for a race win - const width = 45 - const height = 14 - const step = width / Math.max(form.length - 1, 1) - - const points = form.map((val, i) => { - const x = i * step - const y = height - (val / max) * height - return `${x},${y}` - }).join(' ') - - return ( - - - - ) -} - diff --git a/frontend/src/pages/ExplorePage.tsx b/frontend/src/pages/ExplorePage.tsx new file mode 100644 index 0000000..8acff0b --- /dev/null +++ b/frontend/src/pages/ExplorePage.tsx @@ -0,0 +1,156 @@ +import { useMemo } from 'react' +import { useQueries, useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { + fetchChampionshipHub, + fetchLocalMeetings, + fetchSeasonMeetings, + fetchSeasons, + fetchWeekend, +} from '../api' +import { countryFlag, formatGpDateRange } from '../lib/gpIdentity' +import { meetingHasStarted } from '../lib/schedule' +import { teamColor } from '../utils' +import type { Weekend } from '../types' +import '../styles/weekend.css' + +function analysisKey(weekend: Weekend | undefined): number | undefined { + if (!weekend) return undefined + const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race')) + return race?.session.session_key ?? weekend.default_session_key +} + +export function ExplorePage() { + const now = useMemo(() => new Date(), []) + + const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) + const season = seasonsQuery.data?.[0] ?? null + + const localMeetingsQuery = useQuery({ + queryKey: ['meetings', season, 'local'], + queryFn: () => fetchLocalMeetings(season!), + enabled: season != null, + }) + const seasonMeetingsQuery = useQuery({ + queryKey: ['season-meetings', season], + queryFn: () => fetchSeasonMeetings(season!), + enabled: season != null, + }) + const championshipQuery = useQuery({ + queryKey: ['championship-hub', season], + queryFn: () => fetchChampionshipHub(season!), + enabled: season != null, + }) + + const localMeetings = localMeetingsQuery.data ?? [] + const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings + + const weekendQueries = useQueries({ + queries: localMeetings.map((m) => ({ + queryKey: ['weekend', m.meeting_key], + queryFn: () => fetchWeekend(m.meeting_key), + enabled: localMeetings.length > 0, + staleTime: 60_000, + })), + }) + const weekendsByKey = useMemo(() => { + const map = new Map() + localMeetings.forEach((m, i) => { + const data = weekendQueries[i]?.data + if (data) map.set(m.meeting_key, data) + }) + return map + }, [localMeetings, weekendQueries]) + + const drivers = championshipQuery.data?.drivers ?? [] + const activeMeetings = meetings.filter((m) => !m.is_cancelled) + + if (seasonsQuery.isLoading) { + return

Loading…

+ } + + return ( +
+
Explore · {season ?? 'season'}
+ +
+
+ Season races + {activeMeetings.length} rounds +
+ {activeMeetings.length === 0 ? ( +

No season calendar available yet.

+ ) : ( +
    + {activeMeetings.map((meeting, index) => { + const weekend = weekendsByKey.get(meeting.meeting_key) + const target = analysisKey(weekend) + const done = meetingHasStarted(meeting, now) + const card = ( + <> +
    + R{String(index + 1).padStart(2, '0')} + + {countryFlag(meeting) || meeting.country_code} + +
    +
    {meeting.meeting_name}
    +
    {meeting.circuit_short_name || meeting.location}
    +
    {formatGpDateRange(meeting)}
    + + {done ? 'Completed' : 'Upcoming'} + + + ) + return ( +
  1. + {target ? ( + + {card} + + ) : ( +
    {card}
    + )} +
  2. + ) + })} +
+ )} +
+ +
+
+ Drivers + Championship → +
+ {drivers.length === 0 ? ( +

Driver data not available yet.

+ ) : ( +
    + {drivers.map((d) => ( +
  1. + + {d.position} + {d.name_acronym} + {d.points} + +
  2. + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/WeekendPage.tsx b/frontend/src/pages/WeekendPage.tsx new file mode 100644 index 0000000..1ae64a2 --- /dev/null +++ b/frontend/src/pages/WeekendPage.tsx @@ -0,0 +1,94 @@ +import { useWeekendContext } from '../hooks/useWeekendContext' +import { BetweenRacesView } from '../components/weekend/BetweenRacesView' +import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView' +import { LiveHandoffView } from '../components/weekend/LiveHandoffView' +import { PreSessionView } from '../components/weekend/PreSessionView' +import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews' +import { resolveViewState } from '../lib/weekendContext' +import type { + WeekendBriefingItem, + WeekendChampionshipImpact, + WeekendContext, + WeekendViewState, +} from '../types' +import '../styles/weekend.css' + +interface RenderArgs { + context: WeekendContext + now: Date + championship?: WeekendChampionshipImpact + briefing: WeekendBriefingItem[] + /** When true (the /preview alias), foreground the preparation surface. */ + preview: boolean +} + +function renderState(view: WeekendViewState, args: RenderArgs) { + const { context, now, championship, briefing, preview } = args + // The /preview alias always resolves to the preparation surface as long as + // there is a next event to prepare for, regardless of the temporal state. This + // keeps saved /preview links and the "Prepare for …" CTA meaningful instead of + // redirecting straight back to the same between-races screen. + if (preview && context.next_meeting) { + return + } + + switch (view) { + case 'no_season': + return + case 'between_weekends': + case 'post_weekend': + case 'season_complete': + return ( + + ) + case 'between_sessions': + case 'session_settling': + return + case 'session_live': + return + case 'pre_session': + return + default: + return + } +} + +export function WeekendPage({ preview = false }: { preview?: boolean }) { + const { context, loadState, error, championship, briefing, now } = useWeekendContext() + + if (loadState === 'loading') { + return ( +
+ +
+ ) + } + + if (loadState === 'error' || context == null) { + return ( +
+ +
+ ) + } + + const view = resolveViewState(context) + + return ( +
+ {renderState(view, { context, now, championship, briefing, preview })} +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index c60c792..d38430e 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,13 +1,13 @@ import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router' import { Nav } from './components/Nav' -import { CommandCenterPage } from './pages/CommandCenterPage' +import { WeekendPage } from './pages/WeekendPage' +import { ExplorePage } from './pages/ExplorePage' import { RaceHubPage } from './pages/RaceHubPage' import { DataLibraryPage } from './pages/DataLibraryPage' import { LiveTimingPage } from './pages/LiveTimingPage' import { BriefingPage } from './pages/BriefingPage' import { ChampionshipPage } from './pages/ChampionshipPage' import { DriverProfilePage } from './pages/DriverProfilePage' -import { RacePreviewPage } from './pages/RacePreviewPage' type RaceHubSearch = { session_key?: number @@ -26,10 +26,17 @@ const rootRoute = createRootRoute({ ), }) -export const commandCenterRoute = createRoute({ +// Weekend is the adaptive home. `/` renders the current temporal state. +export const weekendRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: CommandCenterPage, + component: WeekendPage, +}) + +export const exploreRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/explore', + component: ExplorePage, }) export const raceHubRoute = createRoute({ @@ -90,14 +97,21 @@ export const briefingRoute = createRoute({ component: BriefingPage, }) +// Preview folds into the Weekend home. /preview is a stable alias that renders the +// Weekend page in its preparation surface (PreSessionView) whenever there is a next +// event — so saved preview links and the "Prepare for …" CTA reach real preview +// content instead of redirecting back to the same between-races screen. export const previewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/preview', - component: RacePreviewPage, + component: function PreviewRoute() { + return + }, }) const routeTree = rootRoute.addChildren([ - commandCenterRoute, + weekendRoute, + exploreRoute, raceHubRoute, adminRoute, dataLibraryRoute, diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 797c6c0..3b98635 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -142,6 +142,53 @@ a { color: inherit; text-decoration: none; } background: var(--surface-2); } +/* ── Mobile bottom nav ── */ +.app-bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + height: 60px; + padding-bottom: env(safe-area-inset-bottom, 0); + background: rgba(18, 18, 18, 0.92); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} +.bottom-nav-link { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + font-size: 10px; + font-weight: 600; + color: var(--text-3); +} +.bottom-nav-link.active { color: var(--red); } +.bottom-nav-link:hover { color: var(--text-2); } + +@media (max-width: 640px) { + /* On mobile the bottom bar is the single primary navigation system. The top + bar's Primary links are hidden so the two are never both shown; the top bar + keeps only the logo and the Admin operator utility. */ + .app-bottom-nav { display: flex; } + .app-nav .nav-links { display: none; } + .app-nav { justify-content: space-between; } + /* Keep page content clear of the fixed bottom bar. */ + .wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); } +} + +/* ── Focus visibility (accessibility) ── */ +:focus-visible { + outline: 2px solid var(--red); + outline-offset: 2px; + border-radius: 3px; +} + /* ── Page ── */ .page { max-width: 1040px; diff --git a/frontend/src/styles/weekend.css b/frontend/src/styles/weekend.css new file mode 100644 index 0000000..2f03a9b --- /dev/null +++ b/frontend/src/styles/weekend.css @@ -0,0 +1,378 @@ +/* ── Weekend adaptive home ── */ +.wk-page { + max-width: 1280px; + margin: 0 auto; + padding: var(--s6) var(--s6) 64px; + width: 100%; +} + +.wk-eyebrow { + display: flex; + align-items: center; + gap: var(--s4); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-2); + margin-bottom: var(--s5); +} +.wk-eyebrow::after { + content: ''; + flex: 1; + height: 1px; + background: linear-gradient(90deg, var(--red), transparent); +} + +.wk-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s4); + margin-bottom: var(--s4); +} +.wk-card-title { + font-size: 13px; + font-weight: 700; + color: var(--text); +} +.wk-card-link { + font-size: 11px; + color: var(--text-2); + white-space: nowrap; +} +.wk-card-link:hover { color: var(--text); } + +.wk-flag { font-size: 22px; line-height: 1; } + +/* ── CTAs ── */ +.wk-cta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s3); + padding: var(--s4) var(--s5); + font-size: 13px; + font-weight: 600; + border-radius: 6px; + transition: background 0.12s, border-color 0.12s, transform 0.12s; +} +.wk-cta-primary { background: var(--red); color: #fff; } +.wk-cta-primary:hover { background: #ff3b3f; } +.wk-cta-ghost { + background: transparent; + color: var(--text-2); + border: 1px solid var(--border-2); +} +.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); } +.wk-cta-wide { width: 100%; } + +/* ── Between-races top grid ── */ +.wk-top-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s5); + margin-bottom: var(--s5); +} +.wk-mid-grid { + display: grid; + grid-template-columns: minmax(0, 340px) minmax(0, 1fr); + gap: var(--s5); + margin-bottom: var(--s5); +} + +.wk-event-card { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: var(--s5); + display: flex; + flex-direction: column; + gap: var(--s5); +} +.wk-event-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); +} +.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; } +.wk-event-name { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; +} +.wk-event-tag { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + white-space: nowrap; +} +.wk-tag-done { color: var(--text-2); } +.wk-tag-next { color: var(--red); } + +.wk-event-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 200px); + gap: var(--s5); + align-items: stretch; +} + +/* ── Podium ── */ +.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); } +.wk-podium-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: var(--s4); + padding: var(--s3) var(--s4); + background: var(--surface-h); + border-left: 3px solid var(--wk-team, var(--border-2)); + border-radius: 4px; +} +.wk-podium-pos { font-weight: 700; color: var(--text-2); } +.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; } +.wk-podium-gap { color: var(--text-2); font-size: 12px; } +.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); } + +.wk-story-card { + display: flex; + flex-direction: column; + gap: var(--s3); + padding: var(--s4); + background: rgba(230, 36, 41, 0.05); + border: 1px solid rgba(230, 36, 41, 0.25); + border-radius: 8px; +} +.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); } +.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; } + +/* ── Next event ── */ +.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); } +.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; } +.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); } +.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; } +.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); } +.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); } +.wk-next-session { display: flex; flex-direction: column; gap: 2px; } +.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); } +.wk-next-value { font-size: 15px; font-weight: 600; } +.wk-season-complete-copy { font-size: 13px; color: var(--text-2); } + +/* ── Championship impact ── */ +.wk-champ, .wk-briefing, .wk-season-nav { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: var(--s5); +} +.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); } +.wk-champ-row { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--s4); + padding: var(--s2) var(--s3); + border-left: 3px solid var(--wk-team, var(--border-2)); +} +.wk-champ-pos { color: var(--text-3); } +.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); } +.wk-champ-pts { font-weight: 700; } +.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; } +.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); } + +/* ── Season progress strip ── */ +.wk-countdown-tbc { font-size: 14px; color: var(--text-3); } +.wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); } +.wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); } +.wk-season-bar { + position: relative; + height: 8px; + border-radius: 999px; + background: var(--surface-3); + overflow: hidden; +} +.wk-season-bar-fill { + position: absolute; + inset: 0 auto 0 0; + background: var(--red); + border-radius: 999px; +} +.wk-season-progress-label { font-size: 11px; color: var(--text-3); } + +/* ── Briefing ── */ +.wk-briefing-list { list-style: none; display: flex; flex-direction: column; } +.wk-briefing-item { border-top: 1px solid var(--border); } +.wk-briefing-item:first-child { border-top: none; } +.wk-briefing-link { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto; + align-items: center; + gap: var(--s4); + padding: var(--s4) 0; +} +.wk-briefing-link:hover .wk-briefing-title { color: var(--red); } +.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; } +.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; } + +/* ── Between-sessions / live ── */ +.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); } +.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; } +.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; } +.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; } + +.wk-timeline { + list-style: none; + display: flex; + gap: var(--s3); + overflow-x: auto; + padding-bottom: var(--s3); + scrollbar-width: thin; +} +.wk-timeline-node { + flex: 1 0 auto; + min-width: 72px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s2); + padding: var(--s3); + border: 1px solid var(--border); + border-radius: 6px; +} +.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); } +.wk-timeline-done .wk-timeline-dot { background: var(--green); } +.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); } +.wk-timeline-next { border-color: var(--red); } +.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); } +.wk-timeline-name { font-size: 12px; font-weight: 600; } +.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); } +.wk-timeline-done .wk-timeline-state { color: var(--green); } +.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); } + +.wk-recap-card, .wk-upnext-card, .wk-live-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: var(--s5); + display: flex; + flex-direction: column; + gap: var(--s4); +} +.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); } +.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; } +.wk-upnext-when { font-size: 12px; color: var(--text-3); } +.wk-upnext-card { border-color: rgba(230,36,41,0.3); } + +.wk-live-card { border-color: rgba(230,36,41,0.35); } +.wk-live-head { display: flex; align-items: center; justify-content: space-between; } +.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); } +.wk-live-lead { font-size: 14px; color: var(--text-2); } + +/* ── Pre-session ── */ +.wk-pre-head { + display: flex; + align-items: center; + gap: var(--s5); + flex-wrap: wrap; +} +.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; } +.wk-pre-preview { min-width: 0; } +/* The folded preview page ships its own max-width page; reset it inside Weekend. */ +.wk-pre-preview .preview-page { max-width: 100%; padding: 0; } + +/* ── Status views ── */ +.wk-status { + max-width: 640px; + margin: 64px auto; + text-align: center; + display: flex; + flex-direction: column; + gap: var(--s4); + align-items: center; +} +.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); } +.wk-status-title { font-size: 22px; font-weight: 700; } +.wk-status-sub { font-size: 13px; color: var(--text-2); } +.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); } +.wk-status-error .wk-status-title { color: var(--red); } + +/* ── Responsive ── */ +@media (max-width: 900px) { + .wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; } + .wk-event-body { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .wk-page { padding: var(--s5) var(--s5) 88px; } + .wk-event-name { font-size: 20px; } + .wk-sessions-title { font-size: 22px; } + .wk-count-unit b { font-size: 32px; } + .wk-pre-head { flex-direction: column; align-items: flex-start; } + .wk-pre-countdown { margin-left: 0; } +} + +/* ── Explore ── */ +.wk-explore { display: flex; flex-direction: column; gap: var(--s6); } +.wk-explore-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: var(--s5); +} +.wk-explore-grid { + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--s4); +} +.wk-explore-card { + display: flex; + flex-direction: column; + gap: var(--s2); + padding: var(--s4); + min-width: 0; + background: var(--surface-h); + border: 1px solid var(--border); + border-radius: 8px; + height: 100%; +} +.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; } +.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); } +.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); } +.wk-explore-flag { font-size: 18px; } +.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; } +.wk-explore-meta { font-size: 11px; color: var(--text-3); } +.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); } +.wk-explore-state.is-done { color: var(--green); } +.wk-explore-state.is-upcoming { color: var(--text-3); } + +.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); } +.wk-driver-chip { + display: inline-flex; + align-items: center; + gap: var(--s3); + padding: var(--s3) var(--s4); + background: var(--surface-h); + border: 1px solid var(--border); + border-left: 3px solid var(--wk-team, var(--border-2)); + border-radius: 6px; + transition: border-color 0.12s; +} +.wk-driver-chip:hover { border-color: var(--text-3); } +.wk-driver-pos { color: var(--text-3); font-size: 11px; } +.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); } +.wk-driver-pts { color: var(--text-2); font-size: 12px; } + +/* ── Reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + .wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; } +} diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx deleted file mode 100644 index 5fbf1c8..0000000 --- a/frontend/src/test/CommandCenterPage.test.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router' -import { CommandCenterPage } from '../pages/CommandCenterPage' -import type { DatasetInfo, Meeting, Weekend } from '../types' - -vi.mock('../api', () => ({ - fetchSeasons: vi.fn(), - fetchLocalMeetings: vi.fn(), - fetchSeasonMeetings: vi.fn(), - fetchSessions: vi.fn(), - fetchWeekend: vi.fn(), - fetchLiveState: vi.fn(), - fetchChampionshipHub: vi.fn(), - fetchRaceHub: vi.fn(), -})) - -import { - fetchSeasons, - fetchLocalMeetings, - fetchSeasonMeetings, - fetchSessions, - fetchWeekend, - fetchLiveState, - fetchChampionshipHub, - fetchRaceHub, -} from '../api' - -const mockFetchSeasons = vi.mocked(fetchSeasons) -const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) -const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings) -const mockFetchSessions = vi.mocked(fetchSessions) -const mockFetchWeekend = vi.mocked(fetchWeekend) -const mockFetchLiveState = vi.mocked(fetchLiveState) -const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub) -const mockFetchRaceHub = vi.mocked(fetchRaceHub) - -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 = { - 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 renderPage() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - - const rootRoute = createRootRoute({ - component: () => ( - - - - ), - }) - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: CommandCenterPage, - }) - - const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) - - return render() -} - -describe('CommandCenterPage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetchLiveState.mockResolvedValue({ is_live: false, data: null }) - mockFetchSessions.mockResolvedValue([]) - mockFetchChampionshipHub.mockResolvedValue({ - season: 2025, - round: 1, - total_rounds: 1, - rounds_left: 0, - last_race: 'Monaco', - round_labels: ['R1'], - drivers: [], - teams: [], - }) - mockFetchRaceHub.mockResolvedValue({ - source: 'local', - session_key: 9472, - datasets: fullDatasets, - results: [ - { - driver_number: 1, - position: 1, - name_acronym: 'VER', - full_name: 'Max Verstappen', - team_name: 'Red Bull Racing', - team_colour: '3671c6', - dnf: false, - dns: false, - dsq: false, - duration: 7200, - gap_to_leader: null, - number_of_laps: 78, - points: 25, - session_key: 9472, - meeting_key: 1229, - }, - ], - starting_grid: [], - drivers: [], - stints: [], - pit_stops: [], - positions: [], - race_control: [], - weather: [], - laps: [], - chapters: [], - }) - }) - - it('shows empty state when no seasons are ingested', async () => { - mockFetchSeasons.mockResolvedValue([]) - - renderPage() - - await waitFor(() => { - expect(screen.getByTestId('command-center-empty')).toBeInTheDocument() - }) - expect(screen.getByText('No local data yet')).toBeInTheDocument() - }) - - it('shows weekend identity band and schedule when data exists', async () => { - mockFetchSeasons.mockResolvedValue([2025]) - mockFetchLocalMeetings.mockResolvedValue([meeting]) - mockFetchSeasonMeetings.mockResolvedValue([meeting]) - mockFetchWeekend.mockResolvedValue(weekend) - - renderPage() - - await waitFor(() => { - expect(screen.getByTestId('command-center')).toBeInTheDocument() - }) - - await waitFor(() => { - expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument() - }) - expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') - expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar') - expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01') - expect(screen.getByText('No live session')).toBeInTheDocument() - expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco') - }) - - it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => { - const canada = { - ...meeting, - meeting_key: 1301, - meeting_name: 'Canada', - country_name: 'Canada', - country_code: 'CAN', - circuit_short_name: 'Montreal', - date_start: '2026-05-22T00:00:00+00:00', - date_end: '2026-05-24T23:59:59+00:00', - year: 2026, - } - const monaco = { - ...meeting, - meeting_key: 1302, - meeting_name: 'Monaco', - date_start: '2026-06-05T00:00:00+00:00', - date_end: '2026-06-07T23:59:59+00:00', - year: 2026, - } - - vi.setSystemTime(new Date('2026-06-06T15:15:00Z')) - mockFetchSeasons.mockResolvedValue([2026]) - mockFetchLocalMeetings.mockResolvedValue([canada]) - mockFetchSeasonMeetings.mockResolvedValue([canada, monaco]) - mockFetchWeekend.mockResolvedValue({ ...weekend, meeting: canada, meeting_key: canada.meeting_key }) - mockFetchSessions.mockResolvedValue([ - { - session_key: 9602, - session_name: 'Qualifying', - session_type: 'Qualifying', - meeting_key: monaco.meeting_key, - date_start: '2026-06-06T15:00:00+00:00', - date_end: '2026-06-06T16:00:00+00:00', - gmt_offset: '02:00:00', - }, - ]) - - renderPage() - - await waitFor(() => { - expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') - }) - expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now') - expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track') - expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live') - - vi.useRealTimers() - }) -}) diff --git a/frontend/src/test/Nav.test.tsx b/frontend/src/test/Nav.test.tsx new file mode 100644 index 0000000..caf9b70 --- /dev/null +++ b/frontend/src/test/Nav.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createRouter, + createRootRoute, + createRoute, + createMemoryHistory, +} from '@tanstack/react-router' +import { Nav } from '../components/Nav' + +function renderNav() { + const rootRoute = createRootRoute({ + component: () => ( + <> +