diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d749b5f..6ef9f70 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,24 @@ export async function fetchWeekend(meetingKey: number): Promise { return res.json() } +// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint +// (sibling backend story #72). It resolves to null when the endpoint is not yet +// available (404 / server without the handler) so the Weekend page can fall back +// to client-side derivation from existing endpoints. Any other HTTP error throws. +export async function fetchWeekendContext(): Promise { + let res: Response + try { + res = await fetch('/api/v1/weekend-context') + } catch { + return null + } + if (res.status === 404) return null + 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..0ef2d82 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -1,36 +1,57 @@ 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 export function Nav() { return ( - +
+ {PRIMARY.map(({ to, label, exact }) => ( + + {label} + + ))} +
+
+ + Admin + +
+ + + + ) } diff --git a/frontend/src/components/weekend/BetweenRacesView.tsx b/frontend/src/components/weekend/BetweenRacesView.tsx new file mode 100644 index 0000000..4b39530 --- /dev/null +++ b/frontend/src/components/weekend/BetweenRacesView.tsx @@ -0,0 +1,121 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight, Flag as FlagIcon } from 'lucide-react' +import type { WeekendContext } from '../../types' +import { + BriefingStrip, + ChampionshipImpactCard, + CountdownDisplay, + EventPodium, + Flag, + SeasonNavStrip, +} from './shared' +import { parseScheduleTime } from '../../lib/schedule' + +const EYEBROW: Record = { + between_races: '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 }: { context: WeekendContext; now: Date }) { + const { last_event, next_event, championship_impact, season_rounds, briefing } = context + const eyebrow = EYEBROW[context.state] ?? 'Between races' + + return ( +
+
{eyebrow}
+ +
+ {last_event && ( +
+
+
+ +

{last_event.meeting_name}

+
+ + +
+
+ +
+ What decided it? + {last_event.story &&

{last_event.story}

} + + Explore Race Story
+
+
+ )} + + {next_event ? ( +
+
+
+ +

{next_event.meeting_name}

+
+ Next event +
+
+ +
+ Next session + + {formatSessionLine(next_event.next_session_name, next_event.next_session_start)} + +
+ + Prepare for {next_event.meeting_name.replace(/ Grand Prix$/i, '')} +
+
+ ) : ( +
+
+

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_impact && } + {season_rounds && season_rounds.length > 0 && } +
+ + {briefing && 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..fc43e18 --- /dev/null +++ b/frontend/src/components/weekend/BetweenSessionsView.tsx @@ -0,0 +1,67 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight } from 'lucide-react' +import type { WeekendContext } from '../../types' +import { BriefingStrip, CountdownDisplay, EventPodium, SessionTimeline } from './shared' +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 }: { context: WeekendContext; now: Date }) { + const settling = context.state === 'session_settling' + const { active_meeting_name, active_circuit_short_name, sessions, last_session, next_session } = context + + return ( +
+
+ {settling ? 'Session settling' : 'Between sessions'} +
+ +
+

{active_meeting_name ?? 'Race weekend'}

+ {active_circuit_short_name &&

{active_circuit_short_name}

} +
+ + {sessions && } + + {last_session && ( +
+ + {last_session.label} · {settling ? 'Settling' : 'Complete'} + +

What happened in {last_session.label}

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

{next_session.session_name}

+ +

{nextSessionWhen(next_session.date_start)}

+ {last_session && ( + + View {last_session.label} recap
+ )} + + {context.briefing && context.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..55d6940 --- /dev/null +++ b/frontend/src/components/weekend/LiveHandoffView.tsx @@ -0,0 +1,42 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight, Radio } from 'lucide-react' +import type { WeekendContext } from '../../types' +import { EventPodium, Flag, SessionTimeline } from './shared' + +export function LiveHandoffView({ context }: { context: WeekendContext }) { + const { active_meeting_name, active_circuit_short_name, sessions, last_session } = context + const liveSession = sessions?.find((s) => s.status === 'live') + + return ( +
+
Session live
+ +
+
+
+ +

{active_meeting_name ?? 'Live session'}

+
+
+ {active_circuit_short_name &&

{active_circuit_short_name}

} +

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

+ +
+ + {sessions && } + + {last_session && ( +
+ {last_session.label} · Complete +

What happened in {last_session.label}

+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/weekend/PreSessionView.tsx b/frontend/src/components/weekend/PreSessionView.tsx new file mode 100644 index 0000000..3db9c60 --- /dev/null +++ b/frontend/src/components/weekend/PreSessionView.tsx @@ -0,0 +1,49 @@ +import { Link } from '@tanstack/react-router' +import { ChevronRight } from 'lucide-react' +import type { WeekendContext } from '../../types' +import { RacePreviewPage } from '../../pages/RacePreviewPage' +import { CountdownDisplay, Flag, SeasonNavStrip, SessionTimeline } from './shared' + +/** + * 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. + */ +export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) { + const next = context.next_event + const nextStart = context.next_session?.date_start ?? next?.next_session_start ?? next?.date_start + + return ( +
+
Pre-session
+ + {next && ( +
+
+ +

{next.meeting_name}

+
+
+ + {context.next_session?.session_name ?? next.next_session_name ?? 'Next session'} + + +
+ + Live timing
+ )} + + {context.sessions && context.sessions.length > 0 && } + +
+ +
+ + {context.season_rounds && context.season_rounds.length > 0 && ( + + )} +
+ ) +} diff --git a/frontend/src/components/weekend/StatusViews.tsx b/frontend/src/components/weekend/StatusViews.tsx new file mode 100644 index 0000000..8e4c054 --- /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..c8f720e --- /dev/null +++ b/frontend/src/components/weekend/shared.tsx @@ -0,0 +1,240 @@ +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 { + WeekendChampionshipImpact, + WeekendCompletedEvent, + WeekendBriefingItem, + WeekendPodiumEntry, + WeekendSeasonRound, + WeekendTimelineSession, +} 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 null + 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 + + ) +} + +export function EventPodium({ event }: { event: WeekendCompletedEvent }) { + const needsFetch = event.podium.length === 0 && event.analysis_session_key > 0 + const query = useQuery({ + queryKey: ['race-hub', event.analysis_session_key, 'podium'], + queryFn: () => fetchRaceHub(event.analysis_session_key), + enabled: needsFetch, + staleTime: 60_000, + }) + + const podium: WeekendPodiumEntry[] = + event.podium.length > 0 ? event.podium : podiumFromResults(query.data?.results ?? []) + + 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 → +
+ +
+ ) +} + +export function SeasonNavStrip({ rounds }: { rounds: WeekendSeasonRound[] }) { + if (rounds.length === 0) return null + return ( +
+
    + {rounds.map((round) => { + const inner = ( + <> + R{String(round.round).padStart(2, '0')} + {countryFlag({ country_code: round.country_code, country_flag: round.country_flag }) || round.country_code} +
+ +
+ ) +} + +export function SessionTimeline({ sessions }: { sessions: WeekendTimelineSession[] }) { + if (sessions.length === 0) return null + return ( +
    + {sessions.map((s) => ( +
  1. +
  2. + ))} +
+ ) +} + +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: WeekendTimelineSession['status']): string { + switch (status) { + case 'done': + return 'Complete' + case 'live': + return 'Live' + case 'next': + return 'Next' + default: + return 'Upcoming' + } +} diff --git a/frontend/src/hooks/useWeekendContext.ts b/frontend/src/hooks/useWeekendContext.ts new file mode 100644 index 0000000..22f14da --- /dev/null +++ b/frontend/src/hooks/useWeekendContext.ts @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQueries, useQuery } from '@tanstack/react-query' +import { + fetchChampionshipHub, + fetchLiveState, + fetchLocalMeetings, + fetchNews, + fetchSeasonMeetings, + fetchSeasons, + fetchSessions, + fetchWeekend, + fetchWeekendContext, +} from '../api' +import { pickFocusMeeting } from '../lib/schedule' +import { deriveWeekendContext } from '../lib/weekendContext' +import type { NewsItem, Weekend, WeekendContext } from '../types' + +export interface UseWeekendContextResult { + context: WeekendContext + /** True when the payload came from the canonical /api/v1/weekend-context endpoint. */ + fromEndpoint: boolean + now: Date +} + +/** + * useWeekendContext prefers the canonical /api/v1/weekend-context endpoint and + * falls back to deriving the same contract client-side from existing endpoints + * so the Weekend home works even when the backend handler (sibling story #72) is + * not yet deployed. + */ +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 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 liveQuery = useQuery({ queryKey: ['live-state'], queryFn: fetchLiveState, staleTime: 5_000 }) + + const newsQuery = useQuery({ queryKey: ['news', 6], queryFn: () => fetchNews(6) }) + + const localMeetings = useMemo(() => localMeetingsQuery.data ?? [], [localMeetingsQuery.data]) + const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : 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 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 focusMeeting = useMemo(() => pickFocusMeeting(seasonMeetings, nowDate), [seasonMeetings, nowDate]) + const focusHasLocal = focusMeeting ? weekendsByKey.has(focusMeeting.meeting_key) : false + + const focusSessionsQuery = useQuery({ + queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'], + queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'), + enabled: focusMeeting != null && !focusHasLocal, + staleTime: 60_000, + }) + + const news: NewsItem[] = newsQuery.data ?? [] + + const derived = useMemo( + () => + deriveWeekendContext({ + season, + meetings: seasonMeetings, + weekendsByKey, + championship: championshipQuery.data, + liveActive: liveQuery.data?.is_live === true, + news, + focusSessions: focusSessionsQuery.data, + now: nowDate, + }), + [ + season, + seasonMeetings, + weekendsByKey, + championshipQuery.data, + liveQuery.data, + news, + focusSessionsQuery.data, + nowDate, + ], + ) + + if (contextQuery.data) { + return { context: contextQuery.data, fromEndpoint: true, now: nowDate } + } + + if (seasonsQuery.isLoading || (season != null && seasonMeetingsQuery.isLoading && localMeetingsQuery.isLoading)) { + return { context: { state: 'loading', season: season ?? 0 }, fromEndpoint: false, now: nowDate } + } + + if (seasonsQuery.isError) { + return { + context: { + state: 'error', + season: 0, + message: seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load Weekend', + }, + fromEndpoint: false, + now: nowDate, + } + } + + return { context: derived, fromEndpoint: false, now: nowDate } +} diff --git a/frontend/src/lib/weekendContext.ts b/frontend/src/lib/weekendContext.ts new file mode 100644 index 0000000..1f3baf5 --- /dev/null +++ b/frontend/src/lib/weekendContext.ts @@ -0,0 +1,390 @@ +import type { + ChampionshipHub, + EnrichedResult, + Meeting, + NewsItem, + Session, + Weekend, + WeekendChampionshipImpact, + WeekendChampionshipMover, + WeekendCompletedEvent, + WeekendContext, + WeekendPodiumEntry, + WeekendSeasonRound, + WeekendState, + WeekendTimelineSession, + WeekendUpcomingEvent, +} from '../types' +import { + currentMeeting, + meetingEndTime, + meetingStartTime, + mostRecentPastMeeting, + nextUpcomingMeeting, + sessionEndTime, + sessionStartTime, + sortSessionsByStart, +} from './schedule' + +// A session that finished within this window is still "settling" — results and +// analysis are landing, so the Weekend surfaces a settling handoff rather than a +// fully-formed recap (see sibling live-settling story #74). +const SETTLING_MS = 45 * 60 * 1000 +// A race that finished within this window keeps the Weekend in its immediate +// post-weekend aftermath before it relaxes into the general between-races cadence. +const POST_WEEKEND_MS = 48 * 60 * 60 * 1000 + +export interface WeekendContextInputs { + season: number | null + meetings: Meeting[] + weekendsByKey: Map + championship?: ChampionshipHub + liveActive: boolean + news: NewsItem[] + /** Sessions for the focus meeting when no local weekend is ingested. */ + focusSessions?: Session[] + now: Date +} + +function isRaceSession(session: Session): boolean { + const type = (session.session_type || '').toLowerCase() + const name = (session.session_name || '').toLowerCase() + return type.includes('race') || name === 'race' || name === 'sprint' +} + +/** Pick the session that best represents this weekend's primary analysis. */ +function pickAnalysisSession(weekend: Weekend | undefined, fallback: Session[]): Session | undefined { + if (weekend && weekend.sessions.length > 0) { + 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) => isRaceSession(s.session)) + if (race) return race.session + const quali = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying')) + if (quali) return quali.session + const byDefault = weekend.sessions.find((s) => s.session.session_key === weekend.default_session_key) + return (byDefault ?? pool[0])?.session + } + const race = fallback.find(isRaceSession) + return race ?? fallback[0] +} + +function weekendSessions(weekend: Weekend | undefined, focusSessions: Session[] | undefined): Session[] { + if (weekend && weekend.sessions.length > 0) return weekend.sessions.map((s) => s.session) + return focusSessions ?? [] +} + +function timeline(sessions: Session[], now: Date): WeekendTimelineSession[] { + const sorted = sortSessionsByStart(sessions) + const nextKey = sorted.find((s) => { + const start = sessionStartTime(s) + return start != null && start > now + })?.session_key + return sorted.map((session) => { + const start = sessionStartTime(session) + const end = sessionEndTime(session) + let status: WeekendTimelineSession['status'] = 'upcoming' + if (start && end && now >= start && now < end) status = 'live' + else if (start && now >= start) status = 'done' + else if (session.session_key === nextKey) status = 'next' + return { + session_key: session.session_key, + session_name: session.session_name, + session_type: session.session_type, + date_start: session.date_start, + date_end: session.date_end, + status, + } + }) +} + +/** Most recently completed session in a weekend, by end time. */ +function lastCompletedSession(sessions: Session[], now: Date): Session | undefined { + let selected: Session | undefined + let latest = -Infinity + for (const session of sessions) { + const end = sessionEndTime(session) + if (!end || end > now) continue + if (end.getTime() > latest) { + latest = end.getTime() + selected = session + } + } + return selected +} + +function meetingRound(meetings: Meeting[], meeting: Meeting): number { + const active = meetings.filter((m) => !m.is_cancelled) + const idx = active.findIndex((m) => m.meeting_key === meeting.meeting_key) + return idx >= 0 ? idx + 1 : 0 +} + +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')}` +} + +function completedEvent( + meeting: Meeting, + meetings: Meeting[], + weekend: Weekend | undefined, + fallback: Session[], +): WeekendCompletedEvent | undefined { + const analysis = pickAnalysisSession(weekend, fallback) + if (!analysis) return undefined + return { + meeting_key: meeting.meeting_key, + meeting_name: meeting.meeting_name, + country_code: meeting.country_code, + country_flag: meeting.country_flag, + circuit_short_name: meeting.circuit_short_name || meeting.location, + round: meetingRound(meetings, meeting) || undefined, + analysis_session_key: analysis.session_key, + analysis_session_name: analysis.session_name, + label: analysis.session_name, + podium: [], + } +} + +function upcomingEvent( + meeting: Meeting, + meetings: Meeting[], + sessions: Session[], + now: Date, +): WeekendUpcomingEvent { + const sorted = sortSessionsByStart(sessions) + const nextSession = sorted.find((s) => { + const start = sessionStartTime(s) + return start != null && start > now + }) + return { + meeting_key: meeting.meeting_key, + meeting_name: meeting.meeting_name, + country_code: meeting.country_code, + country_flag: meeting.country_flag, + circuit_short_name: meeting.circuit_short_name || meeting.location, + circuit_key: meeting.circuit_key, + round: meetingRound(meetings, meeting) || undefined, + date_start: meeting.date_start, + next_session_name: nextSession?.session_name, + next_session_start: nextSession?.date_start, + } +} + +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 } +} + +function seasonRounds( + meetings: Meeting[], + weekendsByKey: Map, + focusKey: number | undefined, + now: Date, +): WeekendSeasonRound[] { + const active = meetings.filter((m) => !m.is_cancelled) + return active.map((meeting, index) => { + const start = meetingStartTime(meeting) + const end = meetingEndTime(meeting) + let status: WeekendSeasonRound['status'] = 'upcoming' + if (meeting.meeting_key === focusKey) status = 'next' + else if (start && end && now > end) status = 'completed' + else if (start && now >= start) status = 'next' + const weekend = weekendsByKey.get(meeting.meeting_key) + const analysis = pickAnalysisSession(weekend, []) + return { + round: index + 1, + meeting_key: meeting.meeting_key, + country_code: meeting.country_code, + country_flag: meeting.country_flag, + status, + analysis_session_key: analysis?.session_key, + } + }) +} + +function briefingItems(news: NewsItem[]): WeekendContext['briefing'] { + 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, + })) +} + +/** + * deriveWeekendContext resolves the current temporal Weekend state and its + * skeleton payload from locally-available data. It is a pure function of its + * inputs and `now`, mirroring the canonical /api/v1/weekend-context contract so + * the two are interchangeable. Podium / story detail is fetched per-state by the + * view components from existing analysis endpoints. + */ +export function deriveWeekendContext(inputs: WeekendContextInputs): WeekendContext { + const { season, meetings, weekendsByKey, championship, liveActive, news, focusSessions, now } = inputs + + const base = (state: WeekendState, extra: Partial = {}): WeekendContext => ({ + state, + season: season ?? championship?.season ?? 0, + live: liveActive, + championship_impact: championshipImpact(championship), + briefing: briefingItems(news), + ...extra, + }) + + if (!season || meetings.length === 0) { + return base('limited_data', { + message: 'No season data yet. Ingest a race weekend to populate the Weekend view.', + }) + } + + const rounds = seasonRounds(meetings, weekendsByKey, undefined, now) + const current = currentMeeting(meetings, now) + const next = nextUpcomingMeeting(meetings, now) + const last = mostRecentPastMeeting(meetings, now) + + const focusKey = current?.meeting_key ?? next?.meeting_key + const withRounds = seasonRounds(meetings, weekendsByKey, focusKey, now) + + // ── Inside a current race weekend window ── + if (current) { + const weekend = weekendsByKey.get(current.meeting_key) + const sessions = weekendSessions(weekend, focusSessions) + const line = timeline(sessions, now) + const nextSession = line.find((s) => s.status === 'next' || s.status === 'live') + const completed = lastCompletedSession(sessions, now) + const lastSession = completed + ? completedEvent(current, meetings, weekend, sessions) + : undefined + if (lastSession && completed) { + lastSession.analysis_session_key = completed.session_key + lastSession.analysis_session_name = completed.session_name + lastSession.label = completed.session_name + } + const anyStarted = sessions.some((s) => { + const start = sessionStartTime(s) + return start != null && now >= start + }) + const allDone = sessions.length > 0 && line.every((s) => s.status === 'done') + const settling = + completed != null && sessionEndTime(completed) != null && + now.getTime() - (sessionEndTime(completed) as Date).getTime() <= SETTLING_MS + + const shared: Partial = { + active_meeting_name: current.meeting_name, + active_circuit_short_name: current.circuit_short_name || current.location, + sessions: line, + next_session: nextSession, + last_session: lastSession, + } + + if (liveActive || line.some((s) => s.status === 'live')) { + return base('session_live', shared) + } + if (settling) { + return base('session_settling', shared) + } + if (allDone) { + // Race weekend finished but still inside its window — treat as post-weekend. + return base('post_weekend', { + ...shared, + last_event: lastSession, + next_event: next ? upcomingEvent(next, meetings, [], now) : undefined, + season_rounds: withRounds, + }) + } + if (lastSession && nextSession) { + return base('between_sessions', shared) + } + if (!anyStarted) { + return base('pre_session', { + ...shared, + next_event: upcomingEvent(current, meetings, sessions, now), + }) + } + return base('between_sessions', shared) + } + + // ── Between weekends ── + if (last) { + const lastWeekend = weekendsByKey.get(last.meeting_key) + const lastEvent = completedEvent(last, meetings, lastWeekend, []) + const lastEnd = meetingEndTime(last) + const isPostWeekend = + lastEnd != null && now.getTime() - lastEnd.getTime() <= POST_WEEKEND_MS + + if (next) { + const nextWeekend = weekendsByKey.get(next.meeting_key) + const nextSessions = weekendSessions(nextWeekend, undefined) + return base(isPostWeekend ? 'post_weekend' : 'between_races', { + last_event: lastEvent, + next_event: upcomingEvent(next, meetings, nextSessions, now), + season_rounds: withRounds, + }) + } + // Completed races but nothing left on the calendar. + return base('season_complete', { + last_event: lastEvent, + season_rounds: rounds, + }) + } + + // ── Season hasn't started yet: preview the opener ── + if (next) { + const nextWeekend = weekendsByKey.get(next.meeting_key) + const nextSessions = weekendSessions(nextWeekend, focusSessions) + return base('pre_session', { + next_event: upcomingEvent(next, meetings, nextSessions, now), + season_rounds: withRounds, + }) + } + + return base('season_complete', { season_rounds: rounds }) +} 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..f9be93f --- /dev/null +++ b/frontend/src/pages/WeekendPage.tsx @@ -0,0 +1,42 @@ +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 type { WeekendContext } from '../types' +import '../styles/weekend.css' + +function renderState(context: WeekendContext, now: Date) { + switch (context.state) { + case 'loading': + return + case 'error': + return + case 'limited_data': + return + case 'between_races': + 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() { + const { context, now } = useWeekendContext() + + return ( +
+ {renderState(context, now)} +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index c60c792..fd01213 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 { createRootRoute, createRoute, createRouter, Outlet, redirect } 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,19 @@ export const briefingRoute = createRoute({ component: BriefingPage, }) +// Preview folds into the Weekend home. Keep /preview as a stable redirect so saved +// links resolve into the appropriate Weekend state. export const previewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/preview', - component: RacePreviewPage, + beforeLoad: () => { + throw redirect({ to: '/' }) + }, }) 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..a7404e9 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -142,6 +142,46 @@ 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) { + .app-bottom-nav { display: flex; } +} + +/* ── 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..98dc5b4 --- /dev/null +++ b/frontend/src/styles/weekend.css @@ -0,0 +1,399 @@ +/* ── 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 nav strip ── */ +.wk-season-strip { + list-style: none; + display: flex; + gap: var(--s3); + overflow-x: auto; + padding-bottom: var(--s3); + scrollbar-width: thin; +} +.wk-season-strip::-webkit-scrollbar { height: 4px; } +.wk-round-item { flex: 0 0 auto; } +.wk-round { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s2); + width: 54px; + padding: var(--s3) 0; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-2); +} +.wk-round-status-next { border-color: var(--red); color: var(--text); } +.wk-round-status-completed { border-color: var(--border-2); } +.wk-round-num { font-size: 10px; font-weight: 700; } +.wk-round-flag { font-size: 16px; } +.wk-round-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.wk-round-completed { background: var(--green); } +.wk-round-next { background: var(--red); } +.wk-round-upcoming { background: var(--surface-3); border: 1px solid var(--border-2); } +.wk-season-legend { + display: flex; + gap: var(--s5); + margin-top: var(--s4); + font-size: 10px; + color: var(--text-3); +} +.wk-season-legend span { display: inline-flex; align-items: center; gap: 5px; } +.wk-season-legend i { width: 7px; height: 7px; } + +/* ── 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/types.ts b/frontend/src/types.ts index 1ff3410..88c71db 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -448,6 +448,120 @@ export interface DriverSummary { rounds: DriverSummaryRound[] } +// ── Weekend Context ── +// Canonical contract for the adaptive Weekend home. Served by +// /api/v1/weekend-context (sibling backend story #72) and, until that endpoint +// ships, derived client-side from existing endpoints (see lib/weekendContext.ts). +export type WeekendState = + | 'loading' + | 'error' + | 'limited_data' + | 'between_races' + | 'pre_session' + | 'between_sessions' + | 'session_live' + | 'session_settling' + | 'post_weekend' + | 'season_complete' + +export interface WeekendPodiumEntry { + position: number + driver_number: number + name_acronym: string + team_name: string + team_colour: string + gap: string +} + +export type WeekendSessionStatus = 'done' | 'live' | 'next' | 'upcoming' + +export interface WeekendTimelineSession { + session_key: number + session_name: string + session_type: string + date_start: string + date_end: string + status: WeekendSessionStatus +} + +export interface WeekendCompletedEvent { + meeting_key: number + meeting_name: string + country_code: string + country_flag: string + circuit_short_name: string + round?: number + analysis_session_key: number + analysis_session_name: string + label: string + podium: WeekendPodiumEntry[] + story?: string +} + +export interface WeekendUpcomingEvent { + meeting_key: number + meeting_name: string + country_code: string + country_flag: string + circuit_short_name: string + circuit_key?: number + round?: number + date_start?: string + next_session_name?: string + next_session_start?: string +} + +export interface WeekendChampionshipMover { + position: number + driver_number: number + name_acronym: string + team_colour: string + points: number + delta?: number +} + +export interface WeekendChampionshipImpact { + leaders: WeekendChampionshipMover[] + note?: string +} + +export interface WeekendBriefingItem { + category?: string + title: string + url: string + source: string + published_at?: string + image_url?: string +} + +export type WeekendRoundStatus = 'completed' | 'next' | 'upcoming' + +export interface WeekendSeasonRound { + round: number + meeting_key: number + country_code: string + country_flag: string + status: WeekendRoundStatus + analysis_session_key?: number +} + +export interface WeekendContext { + state: WeekendState + season: number + message?: string + live?: boolean + active_meeting_name?: string + active_circuit_short_name?: string + last_event?: WeekendCompletedEvent + next_event?: WeekendUpcomingEvent + last_session?: WeekendCompletedEvent + next_session?: WeekendTimelineSession + sessions?: WeekendTimelineSession[] + championship_impact?: WeekendChampionshipImpact + season_rounds?: WeekendSeasonRound[] + briefing?: WeekendBriefingItem[] +} + export interface NewsItem { source: string title: string