diff --git a/.agents/skills/groom/SKILL.md b/.agents/skills/groom/SKILL.md index bc52d18..578db53 100644 --- a/.agents/skills/groom/SKILL.md +++ b/.agents/skills/groom/SKILL.md @@ -1,6 +1,6 @@ --- name: groom -description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom " or "$groom " with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval. +description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom " or "$groom " with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.' argument-hint: [--lens architect] --- diff --git a/.agents/skills/implement/SKILL.md b/.agents/skills/implement/SKILL.md index fd173fe..91386bd 100644 --- a/.agents/skills/implement/SKILL.md +++ b/.agents/skills/implement/SKILL.md @@ -1,6 +1,6 @@ --- name: implement -description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement --harness [--dry-run]" or from any harness terminal with .agents/bin/dev. +description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement --harness [--dry-run]" or from any harness terminal with .agents/bin/dev.' argument-hint: --harness [--dry-run] --- diff --git a/.agents/skills/lens-architect/SKILL.md b/.agents/skills/lens-architect/SKILL.md index ab88289..d01a464 100644 --- a/.agents/skills/lens-architect/SKILL.md +++ b/.agents/skills/lens-architect/SKILL.md @@ -1,6 +1,6 @@ --- name: lens-architect -description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect " for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk. +description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect " for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.' argument-hint: --- diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md index d68a859..50b8d01 100644 --- a/.agents/skills/review/SKILL.md +++ b/.agents/skills/review/SKILL.md @@ -1,6 +1,6 @@ --- name: review -description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate. +description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.' argument-hint: [--harness ] [--publish-screenshots] --- diff --git a/.agents/skills/write-spec/SKILL.md b/.agents/skills/write-spec/SKILL.md index c3dce0d..0393f3a 100644 --- a/.agents/skills/write-spec/SKILL.md +++ b/.agents/skills/write-spec/SKILL.md @@ -1,6 +1,6 @@ --- name: write-spec -description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec " to (re)write an issue's spec from agreed decisions. Does not change Stage. +description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec " to (re)write an issue''s spec from agreed decisions. Does not change Stage.' argument-hint: --- diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9a84579..01ae7e5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -3,6 +3,8 @@ import type { CarDataSample, ChampionshipHub, DriverSummary, + EnrichedGrid, + EnrichedResult, LapsComparisonResponse, LiveStateResponse, LiveSessionMeta, @@ -41,7 +43,11 @@ export async function fetchLocalMeetings(year: number): Promise { } export async function fetchSeasonMeetings(year: number): Promise { - const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`) + return fetchMeetings(year, 'openf1') +} + +export async function fetchMeetings(year: number, source = 'auto'): Promise { + const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`) if (!res.ok) { throw new Error(`API ${res.status}: ${res.statusText}`) } @@ -49,6 +55,32 @@ export async function fetchSeasonMeetings(year: number): Promise { return Array.isArray(meetings) ? meetings : [] } +export async function fetchResults(sessionKey: number, source = 'auto'): Promise { + const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + const results = await res.json() + return Array.isArray(results) ? results : [] +} + +export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise { + const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + const grid = await res.json() + return Array.isArray(grid) ? grid : [] +} + +export async function fetchTrackOutline(circuitKey: number, year: number): Promise { + const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`) + if (!res.ok) return null + const data = await res.json() + if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null + return data as TrackOutline +} + export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise { const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) if (!res.ok) { diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index f337c1c..5542858 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -16,6 +16,9 @@ export function Nav() { Race Hub + + Preview + Championship diff --git a/frontend/src/lib/preview.ts b/frontend/src/lib/preview.ts new file mode 100644 index 0000000..15eec53 --- /dev/null +++ b/frontend/src/lib/preview.ts @@ -0,0 +1,123 @@ +import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types' +import { + currentAndNextSession, + nextUpcomingMeeting, + sessionStartTime, + sortSessionsByStart, +} from './schedule' + +export const RACE_WIN_POINTS = 25 +export const SPRINT_WIN_POINTS = 8 +export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS + +export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null { + const active = meetings.filter((m) => !m.is_cancelled) + return nextUpcomingMeeting(active, now) +} + +export function findPriorYearMeetingByCircuit( + priorYearMeetings: Meeting[], + circuitKey: number | undefined, +): Meeting | null { + if (!circuitKey) return null + return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null +} + +export function findRaceSession(sessions: Session[]): Session | null { + const sorted = sortSessionsByStart(sessions) + return ( + sorted.find((s) => { + const type = s.session_type?.toLowerCase() ?? '' + return type.includes('race') && !type.includes('sprint') + }) ?? null + ) +} + +export function isSprintWeekend(sessions: Session[]): boolean { + return sessions.some((s) => { + const label = `${s.session_type} ${s.session_name}`.toLowerCase() + return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout') + }) +} + +export interface PodiumPlace { + position: number + name_acronym: string + full_name: string + team_name: string + team_colour: string +} + +export interface PolePosition { + name_acronym: string + full_name: string + team_name: string + team_colour: string +} + +export function extractPodium(results: EnrichedResult[]): PodiumPlace[] { + return results + .filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq) + .sort((a, b) => a.position - b.position) + .map((r) => ({ + position: r.position, + name_acronym: r.name_acronym, + full_name: r.full_name, + team_name: r.team_name, + team_colour: r.team_colour, + })) +} + +export function extractPole(grid: EnrichedGrid[]): PolePosition | null { + const pole = grid.find((g) => g.position === 1) + if (!pole) return null + return { + name_acronym: pole.name_acronym, + full_name: pole.full_name, + team_name: pole.team_name, + team_colour: pole.team_colour, + } +} + +export interface TitleFightDriver { + name_acronym: string + full_name: string + team_colour: string + points: number + position: number + gapToLeader: number + gapAfterRaceWin: number + gapAfterSprintWeekendMax: number +} + +export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] { + if (hub.drivers.length === 0) return [] + const leader = hub.drivers[0] + return hub.drivers.slice(0, 3).map((d) => ({ + name_acronym: d.name_acronym, + full_name: d.full_name, + team_colour: d.team_colour, + points: d.points, + position: d.position, + gapToLeader: leader.points - d.points, + gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS), + gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS), + })) +} + +export function countdownTargetSession(sessions: Session[], now: Date): Session | null { + const sorted = sortSessionsByStart(sessions) + const { next } = currentAndNextSession(sorted, now) + if (next) return next + for (const session of sorted) { + const start = sessionStartTime(session) + if (start && start > now) return session + } + return null +} + +export function formatPointsGap(gap: number): string { + if (gap === 0) return 'LEADER' + const sign = gap > 0 ? '+' : '' + return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}` +} diff --git a/frontend/src/pages/RacePreviewPage.tsx b/frontend/src/pages/RacePreviewPage.tsx new file mode 100644 index 0000000..ca6d68a --- /dev/null +++ b/frontend/src/pages/RacePreviewPage.tsx @@ -0,0 +1,442 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + fetchChampionshipHub, + fetchMeetings, + fetchResults, + fetchSeasons, + fetchSessions, + fetchStartingGrid, + fetchTrackOutline, +} from '../api' +import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity' +import { + buildTitleFightContext, + countdownTargetSession, + extractPodium, + extractPole, + findPriorYearMeetingByCircuit, + findRaceSession, + formatPointsGap, + isSprintWeekend, + pickPreviewMeeting, + RACE_WIN_POINTS, + SPRINT_WEEKEND_MAX_POINTS, +} from '../lib/preview' +import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule' +import { buildOutlinePath } from '../lib/trackmap' +import { teamColor } from '../utils' +import type { Meeting, Session, TrackOutline } from '../types' +import '../styles/preview.css' + +function SectionState({ + loading, + error, + empty, + emptyMessage, + children, +}: { + loading?: boolean + error?: Error | null + empty?: boolean + emptyMessage?: string + children: ReactNode +}) { + if (loading) { + return
Loading…
+ } + if (error) { + return ( +
+ {error instanceof Error ? error.message : 'Failed to load'} +
+ ) + } + if (empty) { + return
{emptyMessage ?? 'No data available'}
+ } + return <>{children} +} + +function TrackOutlineCard({ + outline, + loading, + error, + accent, +}: { + outline: TrackOutline | null | undefined + loading: boolean + error: Error | null + accent: string +}) { + const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline]) + + return ( +
+

Circuit

+ +
+ + + + +
+
+
+ ) +} + +function LastYearCard({ + year, + loading, + error, + podium, + pole, + isFirstTime, +}: { + year: number | null + loading: boolean + error: Error | null + podium: ReturnType + pole: ReturnType + isFirstTime: boolean +}) { + return ( +
+

Last year here

+ {year != null && !isFirstTime &&

{year} race weekend

} + +
+ {podium.map((place) => ( +
+ P{place.position} + + {place.name_acronym} + + {place.team_name} +
+ ))} +
+ {pole && ( +

+ Pole: {pole.name_acronym}{' '} + ({pole.team_name}) +

+ )} +
+
+ ) +} + +function TitleFightCard({ + loading, + error, + drivers, + sprintWeekend, + season, +}: { + loading: boolean + error: Error | null + drivers: ReturnType + sprintWeekend: boolean + season: number | null +}) { + return ( +
+

Title fight context

+ {season != null &&

{season} drivers' championship · top 3

} + + + + + + + + + + + + {drivers.map((d) => ( + + + + + + + ))} + +
DriverPtsGapIf win (+{RACE_WIN_POINTS})
+ {d.name_acronym} + {d.points}{formatPointsGap(d.gapToLeader)} + {d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)} +
+ {sprintWeekend && ( +

+ Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win + 8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win. +

+ )} +
+
+ ) +} + +function PreviewHeader({ + meeting, + sessions, + countdownSession, + now, + accent, +}: { + meeting: Meeting + sessions: Session[] + countdownSession: Session | null + now: Date + accent: string +}) { + const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null + + return ( +
+
+
+
+
+

+ {countryFlag(meeting) && {countryFlag(meeting)}} + {meeting.meeting_name} +

+

+ {meeting.circuit_short_name} + {meeting.location ? ` · ${meeting.location}` : ''} + {formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''} +

+
+ {countdownTarget && ( +
+ + {countdownSession?.session_name ?? 'Next session'} + + {formatCountdown(countdownTarget, now)} +
+ )} +
+ {sessions.length > 0 && ( +
+ {sessions.map((session) => ( +
+ {session.session_name} + {formatSessionScheduleTime(session.date_start)} +
+ ))} +
+ )} +
+
+ ) +} + +export function RacePreviewPage() { + 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 seasonsQuery = useQuery({ + queryKey: ['seasons'], + queryFn: fetchSeasons, + }) + + const latestSeason = seasonsQuery.data?.[0] ?? null + + const meetingsQuery = useQuery({ + queryKey: ['meetings', latestSeason, 'auto'], + queryFn: () => fetchMeetings(latestSeason!, 'auto'), + enabled: latestSeason != null, + }) + + const previewMeeting = useMemo( + () => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate), + [meetingsQuery.data, nowDate], + ) + + const sessionsQuery = useQuery({ + queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'], + queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'), + enabled: previewMeeting != null, + }) + + const sessions = useMemo( + () => sortSessionsByStart(sessionsQuery.data ?? []), + [sessionsQuery.data], + ) + + const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate]) + const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions]) + + const priorYear = previewMeeting ? previewMeeting.year - 1 : null + + const priorMeetingsQuery = useQuery({ + queryKey: ['meetings', priorYear, 'auto'], + queryFn: () => fetchMeetings(priorYear!, 'auto'), + enabled: priorYear != null && previewMeeting != null, + }) + + const priorMeeting = useMemo( + () => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key), + [priorMeetingsQuery.data, previewMeeting?.circuit_key], + ) + + const priorSessionsQuery = useQuery({ + queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'], + queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'), + enabled: priorMeeting != null, + }) + + const priorRaceSession = useMemo( + () => findRaceSession(priorSessionsQuery.data ?? []), + [priorSessionsQuery.data], + ) + + const priorResultsQuery = useQuery({ + queryKey: ['results', priorRaceSession?.session_key, 'auto'], + queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'), + enabled: priorRaceSession != null, + }) + + const priorGridQuery = useQuery({ + queryKey: ['grid', priorRaceSession?.session_key, 'auto'], + queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'), + enabled: priorRaceSession != null, + }) + + const trackOutlineQuery = useQuery({ + queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year], + queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year), + enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0, + }) + + const championshipQuery = useQuery({ + queryKey: ['championship-hub', latestSeason], + queryFn: () => fetchChampionshipHub(latestSeason!), + enabled: latestSeason != null, + }) + + const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data]) + const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data]) + const titleFight = useMemo( + () => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []), + [championshipQuery.data], + ) + + const accent = countryAccent(previewMeeting) + const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading + + if (seasonsQuery.isLoading || meetingsQuery.isLoading) { + return
loading preview…
+ } + + if (seasonsQuery.isError || meetingsQuery.isError) { + const err = seasonsQuery.error ?? meetingsQuery.error + return ( +
+ {err instanceof Error ? err.message : 'Failed to load preview'} +
+ ) + } + + if (!previewMeeting) { + return ( +
+
+ box-box · race preview +

Season complete

+

+ No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is + published. +

+
+ {titleFight.length > 0 && ( + + )} +
+ ) + } + + return ( +
+ + +
+ + + +
+ + +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 304579d..c60c792 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -7,6 +7,7 @@ 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 @@ -89,6 +90,12 @@ export const briefingRoute = createRoute({ component: BriefingPage, }) +export const previewRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preview', + component: RacePreviewPage, +}) + const routeTree = rootRoute.addChildren([ commandCenterRoute, raceHubRoute, @@ -98,6 +105,7 @@ const routeTree = rootRoute.addChildren([ championshipRoute, driverProfileRoute, briefingRoute, + previewRoute, ]) export const router = createRouter({ routeTree }) diff --git a/frontend/src/styles/preview.css b/frontend/src/styles/preview.css new file mode 100644 index 0000000..e83f4bd --- /dev/null +++ b/frontend/src/styles/preview.css @@ -0,0 +1,309 @@ +.preview-page { + display: flex; + flex-direction: column; + gap: var(--s6); + padding: var(--s6); + max-width: 1200px; + margin: 0 auto; +} + +.preview-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--s4); + min-height: 320px; + text-align: center; + padding: var(--s8); + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; +} + +.preview-empty-eyebrow { + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); +} + +.preview-empty-title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.preview-empty-sub { + color: var(--text-2); + max-width: 36rem; + margin: 0; +} + +.preview-header { + display: flex; + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + overflow: hidden; + min-height: 140px; +} + +.preview-header-accent { + width: 4px; + flex-shrink: 0; + background: var(--preview-accent, var(--gp-accent)); + box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent)); +} + +.preview-header-body { + flex: 1; + padding: var(--s5); + display: flex; + flex-direction: column; + gap: var(--s4); + min-width: 0; +} + +.preview-header-top { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--s4); +} + +.preview-gp-name { + font-size: 1.4rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: var(--s3); +} + +.preview-gp-flag { + font-size: 1.5rem; + line-height: 1; +} + +.preview-circuit { + color: var(--text-2); + font-size: 0.9rem; + margin: 0; +} + +.preview-countdown { + text-align: right; +} + +.preview-countdown-label { + display: block; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: var(--s1); +} + +.preview-countdown-value { + font-family: var(--mono); + font-size: 1.1rem; + font-variant-numeric: tabular-nums; + color: var(--text-1); +} + +.preview-schedule { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--s2); +} + +.preview-schedule-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--s2) var(--s3); + background: rgba(255, 255, 255, 0.03); + border-radius: 4px; + font-size: 0.8rem; +} + +.preview-schedule-name { + font-weight: 600; + color: var(--text-1); +} + +.preview-schedule-time { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--text-2); +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--s5); +} + +.preview-card { + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: var(--s5); + display: flex; + flex-direction: column; + gap: var(--s4); + min-height: 200px; +} + +.preview-card-title { + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); + margin: 0; +} + +.preview-card-sub { + font-size: 0.85rem; + color: var(--text-2); + margin: calc(-1 * var(--s2)) 0 0; +} + +.preview-track-stage { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 180px; + padding: var(--s4); +} + +.preview-track-svg { + width: 100%; + max-width: 280px; + height: auto; +} + +.preview-track-outline { + fill: none; + stroke: var(--preview-accent, var(--gp-accent)); + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.9; +} + +.preview-track-shadow { + fill: none; + stroke: rgba(0, 0, 0, 0.5); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.preview-podium { + display: flex; + flex-direction: column; + gap: var(--s3); +} + +.preview-podium-row { + display: flex; + align-items: center; + gap: var(--s3); + font-size: 0.9rem; +} + +.preview-podium-pos { + width: 1.5rem; + font-family: var(--mono); + font-weight: 600; + color: var(--text-3); +} + +.preview-podium-code { + font-weight: 600; + min-width: 2.5rem; +} + +.preview-podium-team { + color: var(--text-2); + font-size: 0.8rem; +} + +.preview-pole { + padding-top: var(--s3); + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.85rem; + color: var(--text-2); +} + +.preview-pole strong { + color: var(--text-1); +} + +.preview-title-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.preview-title-table th, +.preview-title-table td { + padding: var(--s2) var(--s3); + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.preview-title-table th { + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); + font-weight: 500; +} + +.preview-title-table td.mono { + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +.preview-sprint-note { + font-size: 0.8rem; + color: var(--text-2); + margin: 0; + padding: var(--s3); + background: rgba(255, 255, 255, 0.03); + border-radius: 4px; +} + +.preview-section-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-3); + font-size: 0.85rem; + text-align: center; + padding: var(--s5); +} + +.preview-section-state.error { + color: var(--red, #e74c3c); +} + +@media (max-width: 640px) { + .preview-page { + padding: var(--s4); + } + + .preview-header-top { + flex-direction: column; + } + + .preview-countdown { + text-align: left; + } +} diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx index 1519c59..5fbf1c8 100644 --- a/frontend/src/test/CommandCenterPage.test.tsx +++ b/frontend/src/test/CommandCenterPage.test.tsx @@ -156,6 +156,7 @@ describe('CommandCenterPage', () => { race_control: [], weather: [], laps: [], + chapters: [], }) }) diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 4e9eb1c..3e90681 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -155,6 +155,7 @@ const raceHub: RaceHub = { is_pit_out_lap: false, }, ], + chapters: [], } const weekend: Weekend = { diff --git a/frontend/src/test/RacePreviewPage.test.tsx b/frontend/src/test/RacePreviewPage.test.tsx new file mode 100644 index 0000000..1ceee9d --- /dev/null +++ b/frontend/src/test/RacePreviewPage.test.tsx @@ -0,0 +1,276 @@ +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 { RacePreviewPage } from '../pages/RacePreviewPage' +import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types' + +vi.mock('../api', () => ({ + fetchSeasons: vi.fn(), + fetchMeetings: vi.fn(), + fetchSessions: vi.fn(), + fetchResults: vi.fn(), + fetchStartingGrid: vi.fn(), + fetchTrackOutline: vi.fn(), + fetchChampionshipHub: vi.fn(), +})) + +import { + fetchSeasons, + fetchMeetings, + fetchSessions, + fetchResults, + fetchStartingGrid, + fetchTrackOutline, + fetchChampionshipHub, +} from '../api' + +const mockFetchSeasons = vi.mocked(fetchSeasons) +const mockFetchMeetings = vi.mocked(fetchMeetings) +const mockFetchSessions = vi.mocked(fetchSessions) +const mockFetchResults = vi.mocked(fetchResults) +const mockFetchStartingGrid = vi.mocked(fetchStartingGrid) +const mockFetchTrackOutline = vi.mocked(fetchTrackOutline) +const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub) + +const upcomingMeeting: Meeting = { + meeting_key: 100, + meeting_name: 'Monaco', + meeting_official_name: 'Monaco GP', + location: 'Monaco', + country_name: 'Monaco', + country_code: 'MON', + country_flag: '', + circuit_key: 10, + circuit_short_name: 'Monaco', + date_start: '2099-05-22T00:00:00+00:00', + date_end: '2099-05-24T23:59:59+00:00', + year: 2099, +} + +const priorMeeting: Meeting = { + ...upcomingMeeting, + meeting_key: 90, + year: 2098, + date_start: '2098-05-22T00:00:00+00:00', + date_end: '2098-05-24T23:59:59+00:00', +} + +const sessions: Session[] = [ + { + session_key: 1, + session_name: 'FP1', + session_type: 'Practice', + meeting_key: 100, + date_start: '2099-05-22T10:00:00+00:00', + date_end: '2099-05-22T11:00:00+00:00', + gmt_offset: '02:00:00', + }, + { + session_key: 2, + session_name: 'Sprint', + session_type: 'Sprint', + meeting_key: 100, + date_start: '2099-05-23T10:00:00+00:00', + date_end: '2099-05-23T11:00:00+00:00', + gmt_offset: '02:00:00', + }, + { + session_key: 3, + session_name: 'Race', + session_type: 'Race', + meeting_key: 100, + date_start: '2099-05-24T13:00:00+00:00', + date_end: '2099-05-24T15:00:00+00:00', + gmt_offset: '02:00:00', + }, +] + +const priorRaceSession: Session = { + session_key: 50, + session_name: 'Race', + session_type: 'Race', + meeting_key: 90, + date_start: '2098-05-24T13:00:00+00:00', + date_end: '2098-05-24T15:00:00+00:00', + gmt_offset: '02:00:00', +} + +const hubDriver = (over: Partial): ChampHubDriver => ({ + driver_number: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + points: 200, + position: 1, + wins: 5, + podiums: 8, + poles: 4, + form: [25], + cumulative: [200], + teammate_wins: 9, + teammate_losses: 1, + ...over, +}) + +const hub: ChampionshipHub = { + season: 2099, + round: 5, + total_rounds: 24, + rounds_left: 19, + last_race: 'Monaco GP', + round_labels: ['R1'], + drivers: [ + hubDriver({}), + hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }), + hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }), + ], + teams: [], +} + +const results: EnrichedResult[] = [ + { + driver_number: 1, + position: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + dnf: false, + dns: false, + dsq: false, + duration: 100, + gap_to_leader: 0, + number_of_laps: 78, + points: 25, + session_key: 50, + meeting_key: 90, + }, +] + +const grid: EnrichedGrid[] = [ + { + driver_number: 4, + position: 1, + name_acronym: 'NOR', + full_name: 'Lando Norris', + team_name: 'McLaren', + team_colour: 'ff8000', + session_key: 50, + meeting_key: 90, + lap_duration: 70, + }, +] + +const outline: TrackOutline = { + circuit_key: 10, + points: [ + { x: 0.1, y: 0.2 }, + { x: 0.5, y: 0.5 }, + { x: 0.9, y: 0.8 }, + ], + bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rootRoute = createRootRoute({ + component: () => ( + + + + ), + }) + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage }) + const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) + return render() +} + +describe('RacePreviewPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchSeasons.mockResolvedValue([2099]) + mockFetchChampionshipHub.mockResolvedValue(hub) + mockFetchTrackOutline.mockResolvedValue(outline) + mockFetchResults.mockResolvedValue(results) + mockFetchStartingGrid.mockResolvedValue(grid) + }) + + it('renders upcoming race preview with sections', async () => { + mockFetchMeetings.mockImplementation(async (year: number) => { + if (year === 2099) return [upcomingMeeting] + if (year === 2098) return [priorMeeting] + return [] + }) + mockFetchSessions.mockImplementation(async (meetingKey: number) => { + if (meetingKey === 100) return sessions + if (meetingKey === 90) return [priorRaceSession] + return [] + }) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('preview-page')).toBeInTheDocument() + }) + + await waitFor(() => { + expect(screen.getByTestId('preview-countdown')).toBeInTheDocument() + }) + + expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco') + expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1') + expect(screen.getByTestId('preview-track-card')).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER') + }) + + expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR') + expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument() + }) + + it('renders season-over state when no upcoming meeting', async () => { + mockFetchMeetings.mockResolvedValue([ + { + ...upcomingMeeting, + date_start: '2020-05-22T00:00:00+00:00', + date_end: '2020-05-24T23:59:59+00:00', + }, + ]) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('preview-season-over')).toBeInTheDocument() + }) + + expect(screen.getByText('Season complete')).toBeInTheDocument() + expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument() + expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument() + }) + + it('shows first-time circuit empty state', async () => { + const newCircuit: Meeting = { + ...upcomingMeeting, + meeting_key: 200, + circuit_key: 999, + meeting_name: 'New GP', + } + + mockFetchMeetings.mockImplementation(async (year: number) => { + if (year === 2099) return [newCircuit] + if (year === 2098) return [priorMeeting] + return [] + }) + mockFetchSessions.mockResolvedValue(sessions) + mockFetchTrackOutline.mockResolvedValue(null) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar') + }) + }) +}) diff --git a/frontend/src/test/preview.test.ts b/frontend/src/test/preview.test.ts new file mode 100644 index 0000000..9429511 --- /dev/null +++ b/frontend/src/test/preview.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest' +import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types' +import { + buildTitleFightContext, + countdownTargetSession, + extractPodium, + extractPole, + findPriorYearMeetingByCircuit, + findRaceSession, + formatPointsGap, + isSprintWeekend, + pickPreviewMeeting, + RACE_WIN_POINTS, + SPRINT_WEEKEND_MAX_POINTS, +} from '../lib/preview' + +const meeting = (overrides: Partial = {}): Meeting => ({ + meeting_key: 1, + meeting_name: 'Monaco', + meeting_official_name: 'Monaco GP', + location: 'Monaco', + country_name: 'Monaco', + country_code: 'MON', + country_flag: '', + circuit_key: 10, + circuit_short_name: 'Monaco', + date_start: '2026-05-22T00:00:00+00:00', + date_end: '2026-05-24T23:59:59+00:00', + year: 2026, + ...overrides, +}) + +const session = (overrides: Partial = {}): Session => ({ + session_key: 100, + session_name: 'Race', + session_type: 'Race', + meeting_key: 1, + date_start: '2026-05-24T13:00:00+00:00', + date_end: '2026-05-24T15:00:00+00:00', + gmt_offset: '02:00:00', + ...overrides, +}) + +const hubDriver = (over: Partial): ChampHubDriver => ({ + driver_number: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + points: 200, + position: 1, + wins: 5, + podiums: 8, + poles: 4, + form: [25], + cumulative: [200], + teammate_wins: 9, + teammate_losses: 1, + ...over, +}) + +describe('preview lib', () => { + it('picks the next upcoming meeting', () => { + const now = new Date('2026-01-01T00:00:00Z') + const meetings = [ + meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }), + meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }), + ] + expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1) + }) + + it('returns null when no upcoming meeting exists', () => { + const now = new Date('2027-01-01T00:00:00Z') + const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })] + expect(pickPreviewMeeting(meetings, now)).toBeNull() + }) + + it('skips cancelled meetings', () => { + const now = new Date('2026-01-01T00:00:00Z') + const meetings = [meeting({ is_cancelled: true })] + expect(pickPreviewMeeting(meetings, now)).toBeNull() + }) + + it('matches prior-year meeting by circuit_key', () => { + const prior = [ + meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }), + meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }), + ] + expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50) + expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull() + expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull() + }) + + it('finds the grand prix race session', () => { + const sessions = [ + session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }), + session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }), + session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }), + ] + expect(findRaceSession(sessions)?.session_key).toBe(3) + }) + + it('detects sprint weekends', () => { + const sprint = [ + session({ session_type: 'Practice' }), + session({ session_name: 'Sprint', session_type: 'Sprint' }), + session({ session_type: 'Race' }), + ] + const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })] + expect(isSprintWeekend(sprint)).toBe(true) + expect(isSprintWeekend(normal)).toBe(false) + }) + + it('extracts podium and pole', () => { + const results: EnrichedResult[] = [ + { + driver_number: 1, + position: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + dnf: false, + dns: false, + dsq: false, + duration: 100, + gap_to_leader: 0, + number_of_laps: 78, + points: 25, + session_key: 1, + meeting_key: 1, + }, + { + driver_number: 4, + position: 2, + name_acronym: 'NOR', + full_name: 'Lando Norris', + team_name: 'McLaren', + team_colour: 'ff8000', + dnf: false, + dns: false, + dsq: false, + duration: 101, + gap_to_leader: 1, + number_of_laps: 78, + points: 18, + session_key: 1, + meeting_key: 1, + }, + { + driver_number: 16, + position: 3, + name_acronym: 'LEC', + full_name: 'Charles Leclerc', + team_name: 'Ferrari', + team_colour: 'e8002d', + dnf: false, + dns: false, + dsq: false, + duration: 102, + gap_to_leader: 2, + number_of_laps: 78, + points: 15, + session_key: 1, + meeting_key: 1, + }, + ] + + const grid: EnrichedGrid[] = [ + { + driver_number: 4, + position: 1, + name_acronym: 'NOR', + full_name: 'Lando Norris', + team_name: 'McLaren', + team_colour: 'ff8000', + session_key: 1, + meeting_key: 1, + lap_duration: 70, + }, + ] + + const podium = extractPodium(results) + expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC']) + expect(extractPole(grid)?.name_acronym).toBe('NOR') + }) + + it('builds title fight gaps for top 3', () => { + const hub: ChampionshipHub = { + season: 2026, + round: 5, + total_rounds: 24, + rounds_left: 19, + last_race: 'Monaco GP', + round_labels: ['R1'], + drivers: [ + hubDriver({ points: 200, position: 1 }), + hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }), + hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }), + ], + teams: [], + } + + const rows = buildTitleFightContext(hub) + expect(rows).toHaveLength(3) + expect(rows[0].gapToLeader).toBe(0) + expect(rows[1].gapToLeader).toBe(40) + expect(rows[1].gapAfterRaceWin).toBe(15) + expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS)) + expect(RACE_WIN_POINTS).toBe(25) + }) + + it('formats points gaps', () => { + expect(formatPointsGap(0)).toBe('LEADER') + expect(formatPointsGap(40)).toBe('+40') + }) + + it('picks countdown target as next future session', () => { + const sessions = [ + session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }), + session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }), + ] + const now = new Date('2026-05-23T12:00:00Z') + expect(countdownTargetSession(sessions, now)?.session_key).toBe(2) + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 85c8264..1d801f0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,6 +12,7 @@ export interface Meeting { country_name: string country_code: string country_flag: string + circuit_key?: number circuit_short_name: string date_start: string date_end: string @@ -23,6 +24,7 @@ export interface Session { session_key: number session_name: string session_type: string + circuit_key?: number meeting_key: number date_start: string date_end: string @@ -89,6 +91,17 @@ export interface RaceHub { race_control: RaceControlMessage[] weather: WeatherSample[] laps: Lap[] + chapters: Chapter[] +} + +export interface Chapter { + kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string + title: string + start_lap: number + end_lap: number + start_time?: string + end_time?: string + driver_numbers: number[] } export interface Stint { diff --git a/internal/chapters/chapters.go b/internal/chapters/chapters.go new file mode 100644 index 0000000..d5a937c --- /dev/null +++ b/internal/chapters/chapters.go @@ -0,0 +1,628 @@ +package chapters + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +const ( + KindStart = "start" + KindSafetyCar = "safety_car" + KindVirtualSafetyCar = "virtual_safety_car" + KindRedFlag = "red_flag" + KindPitPhase = "pit_phase" + KindDecisiveSwing = "decisive_swing" + KindFinish = "finish" + + pitPhaseWindowLaps = 3 + pitPhaseShare = 0.30 + minPitPhaseStops = 2 + maxDecisiveSwings = 3 + decisiveAfterLap = 5 + structuralPriority = 110 + flagPriority = 100 + pitPhasePriority = 50 + decisivePriority = 40 +) + +type RaceControl = models.RaceControl +type PositionSample = models.Position +type Lap = models.Lap + +// Chapter is a deterministic replay segment derived from timing and race-control data. +type Chapter struct { + Kind string `json:"kind"` + Title string `json:"title"` + StartLap int `json:"start_lap"` + EndLap int `json:"end_lap"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + DriverNumbers []int `json:"driver_numbers"` +} + +// Detect builds replay chapters from already-loaded race-hub datasets. +func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter { + totalLaps = normalizeTotalLaps(totalLaps, laps, rc) + if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 { + return []Chapter{} + } + if totalLaps <= 0 { + totalLaps = 1 + } + + lapIndex := buildLapIndex(laps) + chapters := []Chapter{ + { + Kind: KindStart, + Title: "Start", + StartLap: 1, + EndLap: minInt(1, totalLaps), + StartTime: lapIndex.lapStart(1), + EndTime: lapIndex.lapEnd(1), + }, + } + chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...) + chapters = append(chapters, detectPitPhases(laps, lapIndex)...) + chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...) + chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps)) + + return resolveConflicts(chapters) +} + +func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int { + for _, l := range laps { + if l.LapNumber > totalLaps { + totalLaps = l.LapNumber + } + } + for _, msg := range rc { + if msg.LapNumber != nil && *msg.LapNumber > totalLaps { + totalLaps = *msg.LapNumber + } + } + return totalLaps +} + +type lapIndex struct { + byLap map[int]string + events []lapEvent +} + +type lapEvent struct { + lap int + at time.Time +} + +func buildLapIndex(laps []Lap) lapIndex { + idx := lapIndex{byLap: map[int]string{}} + for _, l := range laps { + if l.LapNumber <= 0 || l.DateStart == "" { + continue + } + if _, ok := idx.byLap[l.LapNumber]; !ok { + idx.byLap[l.LapNumber] = l.DateStart + } + at, ok := parseTime(l.DateStart) + if ok { + idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at}) + } + } + sort.Slice(idx.events, func(i, j int) bool { + if idx.events[i].at.Equal(idx.events[j].at) { + return idx.events[i].lap < idx.events[j].lap + } + return idx.events[i].at.Before(idx.events[j].at) + }) + return idx +} + +func (idx lapIndex) lapStart(lap int) string { + return idx.byLap[lap] +} + +func (idx lapIndex) lapEnd(lap int) string { + if v := idx.byLap[lap+1]; v != "" { + return v + } + return idx.byLap[lap] +} + +func (idx lapIndex) lapForTime(raw string) int { + at, ok := parseTime(raw) + if !ok || len(idx.events) == 0 { + return 0 + } + lap := 0 + for _, event := range idx.events { + if event.at.After(at) { + break + } + lap = event.lap + } + if lap == 0 { + return idx.events[0].lap + } + return lap +} + +type flagState struct { + startLap int + startTime string +} + +func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter { + var chapters []Chapter + active := map[string]flagState{} + for _, msg := range rc { + kind, ok := flagKind(msg) + if !ok && greenFlagClear(msg) { + for activeKind, st := range active { + lap := messageLap(msg, idx) + if lap <= 0 { + lap = st.startLap + } + endLap := clampLap(lap, st.startLap, totalLaps) + chapters = append(chapters, Chapter{ + Kind: activeKind, + Title: flagTitle(activeKind, st.startLap, endLap), + StartLap: st.startLap, + EndLap: endLap, + StartTime: st.startTime, + EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)), + }) + delete(active, activeKind) + } + continue + } + if !ok { + continue + } + lap := messageLap(msg, idx) + if lap <= 0 { + lap = 1 + } + if flagCleared(msg) { + st, ok := active[kind] + if !ok { + continue + } + endLap := clampLap(lap, st.startLap, totalLaps) + chapters = append(chapters, Chapter{ + Kind: kind, + Title: flagTitle(kind, st.startLap, endLap), + StartLap: st.startLap, + EndLap: endLap, + StartTime: st.startTime, + EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)), + }) + delete(active, kind) + continue + } + if flagStarted(msg) { + active[kind] = flagState{ + startLap: clampLap(lap, 1, totalLaps), + startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)), + } + } + } + for kind, st := range active { + endLap := totalLaps + chapters = append(chapters, Chapter{ + Kind: kind, + Title: flagTitle(kind, st.startLap, endLap), + StartLap: st.startLap, + EndLap: endLap, + StartTime: st.startTime, + EndTime: idx.lapEnd(endLap), + }) + } + return chapters +} + +func flagKind(msg RaceControl) (string, bool) { + text := upperText(string(msg.Category), string(msg.Flag), msg.Message) + if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") { + return KindVirtualSafetyCar, true + } + if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) { + return KindRedFlag, true + } + if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar { + return KindSafetyCar, true + } + return "", false +} + +func flagStarted(msg RaceControl) bool { + text := upperText(string(msg.Category), string(msg.Flag), msg.Message) + if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") { + return false + } + return strings.Contains(text, "DEPLOY") || + strings.Contains(text, "RED FLAG") || + strings.Contains(text, "VIRTUAL SAFETY CAR") || + strings.Contains(text, "VSC") || + strings.Contains(text, "SAFETY CAR") || + string(msg.Flag) == string(models.FlagRed) +} + +func flagCleared(msg RaceControl) bool { + text := upperText(string(msg.Category), string(msg.Flag), msg.Message) + return strings.Contains(text, "CLEAR") || + strings.Contains(text, "ENDING") || + strings.Contains(text, "IN THIS LAP") || + strings.Contains(text, "GREEN") +} + +func greenFlagClear(msg RaceControl) bool { + text := upperText(string(msg.Flag), msg.Message) + return strings.Contains(text, "GREEN") +} + +func flagTitle(kind string, startLap, endLap int) string { + name := "Flag period" + switch kind { + case KindSafetyCar: + name = "Safety Car" + case KindVirtualSafetyCar: + name = "Virtual Safety Car" + case KindRedFlag: + name = "Red Flag" + } + return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap) +} + +func detectPitPhases(laps []Lap, idx lapIndex) []Chapter { + type pitOut struct { + lap int + driver int + } + var stops []pitOut + for _, l := range laps { + if l.IsPitOutLap && l.LapNumber > 0 { + stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber}) + } + } + if len(stops) < minPitPhaseStops { + return nil + } + sort.Slice(stops, func(i, j int) bool { + if stops[i].lap == stops[j].lap { + return stops[i].driver < stops[j].driver + } + return stops[i].lap < stops[j].lap + }) + needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare)) + if needed < minPitPhaseStops { + needed = minPitPhaseStops + } + + var windows []Chapter + for i := 0; i < len(stops); i++ { + start := stops[i].lap + end := start + pitPhaseWindowLaps - 1 + drivers := map[int]bool{} + count := 0 + for _, stop := range stops { + if stop.lap < start || stop.lap > end { + continue + } + count++ + drivers[stop.driver] = true + } + if count < needed { + continue + } + ch := Chapter{ + Kind: KindPitPhase, + Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end), + StartLap: start, + EndLap: end, + StartTime: idx.lapStart(start), + EndTime: idx.lapEnd(end), + DriverNumbers: sortedDriverNumbers(drivers), + } + if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 { + last := &windows[len(windows)-1] + if ch.EndLap > last.EndLap { + last.EndLap = ch.EndLap + last.EndTime = idx.lapEnd(last.EndLap) + } + drivers := sliceToSet(last.DriverNumbers) + for _, driver := range ch.DriverNumbers { + drivers[driver] = true + } + last.DriverNumbers = sortedDriverNumbers(drivers) + last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap) + continue + } + windows = append(windows, ch) + } + return windows +} + +type swingCandidate struct { + chapter Chapter + significance int +} + +func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter { + if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap { + return nil + } + snapshots := buildPositionSnapshots(positions, idx, totalLaps) + if len(snapshots) == 0 { + return nil + } + final := snapshots[totalLaps] + if len(final) == 0 { + for lap := totalLaps - 1; lap >= 1; lap-- { + if len(snapshots[lap]) > 0 { + final = snapshots[lap] + break + } + } + } + var candidates []swingCandidate + seenDriver := map[int]bool{} + for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ { + prev := snapshots[lap-1] + curr := snapshots[lap] + if len(prev) == 0 || len(curr) == 0 { + continue + } + for driver, pos := range curr { + prevPos, ok := prev[driver] + if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] { + continue + } + finalPos, ok := final[driver] + if !ok || finalPos > pos { + continue + } + overtaken := driverAtPosition(curr, prevPos, driver) + drivers := []int{driver} + if overtaken != 0 { + drivers = append(drivers, overtaken) + } + candidates = append(candidates, swingCandidate{ + chapter: Chapter{ + Kind: KindDecisiveSwing, + Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap), + StartLap: lap, + EndLap: lap, + StartTime: idx.lapStart(lap), + EndTime: idx.lapEnd(lap), + DriverNumbers: drivers, + }, + significance: (prevPos-pos)*10 + (6 - pos), + }) + seenDriver[driver] = true + } + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].significance == candidates[j].significance { + return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap + } + return candidates[i].significance > candidates[j].significance + }) + if len(candidates) > maxDecisiveSwings { + candidates = candidates[:maxDecisiveSwings] + } + out := make([]Chapter, 0, len(candidates)) + for _, c := range candidates { + out = append(out, c.chapter) + } + return out +} + +func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int { + byLap := map[int][]PositionSample{} + for _, p := range positions { + if p.Position <= 0 { + continue + } + lap := idx.lapForTime(p.Date) + if lap <= 0 || lap > totalLaps { + continue + } + byLap[lap] = append(byLap[lap], p) + } + last := map[int]int{} + snapshots := map[int]map[int]int{} + for lap := 1; lap <= totalLaps; lap++ { + for _, p := range byLap[lap] { + last[p.DriverNumber] = p.Position + } + if len(last) == 0 { + continue + } + cp := make(map[int]int, len(last)) + for driver, pos := range last { + cp[driver] = pos + } + snapshots[lap] = cp + } + return snapshots +} + +func driverAtPosition(snapshot map[int]int, pos int, exclude int) int { + for driver, driverPos := range snapshot { + if driver != exclude && driverPos == pos { + return driver + } + } + return 0 +} + +func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter { + finishLap := totalLaps + finishTime := idx.lapEnd(totalLaps) + for _, msg := range rc { + text := upperText(string(msg.Flag), msg.Message) + if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) { + if lap := messageLap(msg, idx); lap > 0 { + finishLap = lap + } + finishTime = firstNonEmpty(msg.Date, finishTime) + } + } + startLap := finishLap - 1 + if startLap < 1 { + startLap = 1 + } + return Chapter{ + Kind: KindFinish, + Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap), + StartLap: startLap, + EndLap: finishLap, + StartTime: idx.lapStart(startLap), + EndTime: finishTime, + } +} + +func resolveConflicts(chapters []Chapter) []Chapter { + normalized := make([]Chapter, 0, len(chapters)) + for _, ch := range chapters { + if ch.StartLap <= 0 { + ch.StartLap = 1 + } + if ch.EndLap <= 0 { + ch.EndLap = ch.StartLap + } + if ch.EndLap < ch.StartLap { + ch.EndLap = ch.StartLap + } + if ch.DriverNumbers == nil { + ch.DriverNumbers = []int{} + } + normalized = append(normalized, ch) + } + sort.SliceStable(normalized, func(i, j int) bool { + if normalized[i].StartLap == normalized[j].StartLap { + return priority(normalized[i].Kind) > priority(normalized[j].Kind) + } + return normalized[i].StartLap < normalized[j].StartLap + }) + + out := make([]Chapter, 0, len(normalized)) + for _, ch := range normalized { + if len(out) == 0 { + out = append(out, ch) + continue + } + last := &out[len(out)-1] + if ch.StartLap > last.EndLap { + out = append(out, ch) + continue + } + if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) { + continue + } + if priority(ch.Kind) > priority(last.Kind) { + if last.StartLap < ch.StartLap { + last.EndLap = ch.StartLap - 1 + out = append(out, ch) + } else { + *last = ch + } + continue + } + if ch.EndLap > last.EndLap { + ch.StartLap = last.EndLap + 1 + if ch.StartLap <= ch.EndLap { + out = append(out, ch) + } + } + } + return out +} + +func isFlag(kind string) bool { + return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag +} + +func priority(kind string) int { + switch kind { + case KindStart, KindFinish: + return structuralPriority + case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag: + return flagPriority + case KindPitPhase: + return pitPhasePriority + case KindDecisiveSwing: + return decisivePriority + default: + return 0 + } +} + +func messageLap(msg RaceControl, idx lapIndex) int { + if msg.LapNumber != nil && *msg.LapNumber > 0 { + return *msg.LapNumber + } + return idx.lapForTime(msg.Date) +} + +func clampLap(lap, minLap, maxLap int) int { + if lap < minLap { + return minLap + } + if maxLap > 0 && lap > maxLap { + return maxLap + } + return lap +} + +func parseTime(raw string) (time.Time, bool) { + if raw == "" { + return time.Time{}, false + } + at, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return at, true +} + +func upperText(parts ...string) string { + return strings.ToUpper(strings.Join(parts, " ")) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func sortedDriverNumbers(drivers map[int]bool) []int { + out := make([]int, 0, len(drivers)) + for driver := range drivers { + out = append(out, driver) + } + sort.Ints(out) + return out +} + +func sliceToSet(values []int) map[int]bool { + out := make(map[int]bool, len(values)) + for _, value := range values { + out[value] = true + } + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/chapters/chapters_test.go b/internal/chapters/chapters_test.go new file mode 100644 index 0000000..fb58637 --- /dev/null +++ b/internal/chapters/chapters_test.go @@ -0,0 +1,218 @@ +package chapters + +import ( + "fmt" + "testing" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +func TestDetectStructuralChapters(t *testing.T) { + chapters := Detect(nil, nil, testLaps(10, nil), 10) + + if len(chapters) < 2 { + t.Fatalf("chapters len = %d, want at least start and finish", len(chapters)) + } + if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 { + t.Fatalf("start chapter = %+v, want L1-L1", got) + } + got := chapters[len(chapters)-1] + if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 { + t.Fatalf("finish chapter = %+v, want L9-L10", got) + } +} + +func TestDetectReturnsEmptyWithoutData(t *testing.T) { + if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 { + t.Fatalf("chapters = %+v, want empty", chapters) + } +} + +func TestDetectFlagPeriods(t *testing.T) { + tests := []struct { + name string + start RaceControl + end RaceControl + wantKind string + wantTitle string + }{ + { + name: "safety car", + start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"), + end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"), + wantKind: KindSafetyCar, + wantTitle: "Safety Car (L12-L15)", + }, + { + name: "virtual safety car", + start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"), + end: rc(24, models.CategoryOther, "", "VSC ENDING"), + wantKind: KindVirtualSafetyCar, + wantTitle: "Virtual Safety Car (L22-L24)", + }, + { + name: "red flag", + start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"), + end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"), + wantKind: KindRedFlag, + wantTitle: "Red Flag (L31-L33)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40) + got := findKind(chapters, tt.wantKind) + if got == nil { + t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind) + } + if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle { + t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle) + } + }) + } +} + +func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) { + pitOuts := map[int][]int{ + 5: {1}, + 10: {2}, + 20: {3}, + 21: {4}, + 22: {5}, + 30: {6}, + 35: {7}, + 40: {8}, + 45: {9}, + 50: {10}, + } + chapters := Detect(nil, nil, testLaps(55, pitOuts), 55) + + got := findKind(chapters, KindPitPhase) + if got == nil { + t.Fatalf("chapters = %+v, want pit phase", chapters) + } + if got.StartLap != 20 || got.EndLap != 22 { + t.Fatalf("pit phase = %+v, want L20-L22", *got) + } + if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 { + t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers) + } +} + +func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) { + positions := []PositionSample{ + pos(1, 1, 1), + pos(1, 16, 4), + pos(1, 55, 3), + pos(6, 16, 3), + pos(6, 55, 4), + pos(8, 44, 5), + pos(8, 63, 6), + pos(10, 44, 6), + } + + chapters := Detect(nil, positions, testLaps(12, nil), 12) + got := findKind(chapters, KindDecisiveSwing) + if got == nil { + t.Fatalf("chapters = %+v, want decisive swing", chapters) + } + if got.StartLap != 6 || got.EndLap != 6 { + t.Fatalf("swing lap = %+v, want L6", *got) + } + if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 { + t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers) + } +} + +func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) { + pitOuts := map[int][]int{ + 12: {1, 2}, + 13: {3, 4}, + 14: {5, 6}, + } + rcs := []RaceControl{ + rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"), + rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"), + } + + chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20) + if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 { + t.Fatalf("chapters = %+v, want safety car L12-L15", chapters) + } + if got := findKind(chapters, KindPitPhase); got != nil { + t.Fatalf("pit phase = %+v, want omitted under safety car", *got) + } + for i := 1; i < len(chapters); i++ { + if chapters[i].StartLap <= chapters[i-1].EndLap { + t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i]) + } + } +} + +func rcLap(r RaceControl) int { + if r.LapNumber == nil { + return 0 + } + return *r.LapNumber +} + +func findKind(chapters []Chapter, kind string) *Chapter { + for i := range chapters { + if chapters[i].Kind == kind { + return &chapters[i] + } + } + return nil +} + +func testLaps(total int, pitOuts map[int][]int) []Lap { + var laps []Lap + for lap := 1; lap <= total; lap++ { + drivers := []int{1} + if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 { + drivers = pitDrivers + } + for _, driver := range drivers { + laps = append(laps, Lap{ + DriverNumber: driver, + LapNumber: lap, + DateStart: lapTime(lap), + IsPitOutLap: containsDriver(pitOuts[lap], driver), + }) + } + } + return laps +} + +func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl { + return RaceControl{ + Category: category, + Flag: flag, + Message: message, + LapNumber: &lap, + Date: lapTime(lap), + } +} + +func pos(lap int, driver int, position int) PositionSample { + return PositionSample{ + DriverNumber: driver, + Position: position, + Date: lapTime(lap), + } +} + +func lapTime(lap int) string { + minute := lap - 1 + return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute) +} + +func containsDriver(drivers []int, driver int) bool { + for _, candidate := range drivers { + if candidate == driver { + return true + } + } + return false +} diff --git a/internal/query/racehub.go b/internal/query/racehub.go index 1924599..5d15884 100644 --- a/internal/query/racehub.go +++ b/internal/query/racehub.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" + "github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/store" ) @@ -52,6 +53,7 @@ type RaceHub struct { RaceControl []models.RaceControl `json:"race_control"` Weather []models.Weather `json:"weather"` Laps []models.Lap `json:"laps"` + Chapters []chapters.Chapter `json:"chapters"` } // GetRaceHub loads ingested Race Hub datasets for a session from the local store. @@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) { RaceControl: []models.RaceControl{}, Weather: []models.Weather{}, Laps: []models.Lap{}, + Chapters: []chapters.Chapter{}, } sess, err := s.store.GetSession(sessionKey) @@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) { hub.Datasets["laps"] = availableLocal(len(hub.Laps)) } + hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps)) hub.Source = responseSource(hub.Datasets) return hub, nil } + +func totalLaps(results []EnrichedResult, laps []models.Lap) int { + total := 0 + for _, result := range results { + if result.NumberOfLaps > total { + total = result.NumberOfLaps + } + } + for _, lap := range laps { + if lap.LapNumber > total { + total = lap.LapNumber + } + } + return total +} diff --git a/internal/web/racehub.go b/internal/web/racehub.go index 3f43955..db8725e 100644 --- a/internal/web/racehub.go +++ b/internal/web/racehub.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/query" ) @@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub { Drivers: []models.Driver{}, Results: []query.EnrichedResult{}, StartingGrid: []query.EnrichedGrid{}, + Chapters: []chapters.Chapter{}, } } diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go index fd95b7f..50755d5 100644 --- a/internal/web/racehub_test.go +++ b/internal/web/racehub_test.go @@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) { } } +func TestHandleRaceHubIncludesChapters(t *testing.T) { + st := openTestStore(t) + seedRaceHubStore(t, st) + sessionKey := 9472 + meetingKey := 1229 + + for lap := 1; lap <= 12; lap++ { + if err := st.UpsertLap(store.Lap{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + LapNumber: lap, + DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339), + LapDuration: 75, + }); err != nil { + t.Fatalf("UpsertLap(%d) error = %v", lap, err) + } + } + for _, sample := range []store.PositionSample{ + {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1}, + {SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4}, + {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3}, + {SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3}, + {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4}, + } { + if err := st.UpsertPositionSample(sample); err != nil { + t.Fatalf("UpsertPositionSample() error = %v", err) + } + } + + srv := testServer(t, st) + req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil) + rec := httptest.NewRecorder() + srv.handleRaceHub(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var hub query.RaceHub + if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(hub.Chapters) == 0 { + t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters) + } + if hub.Chapters[0].Kind != "start" { + t.Fatalf("first chapter = %+v, want start", hub.Chapters[0]) + } + foundSwing := false + for _, chapter := range hub.Chapters { + if chapter.Kind == "decisive_swing" { + foundSwing = true + } + } + if !foundSwing { + t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters) + } +} + func TestHandleMeetingsSourceLocal(t *testing.T) { st := openTestStore(t) seedRaceHubStore(t, st)