From e8de2068c13764b5ba933d338397438e088ec341 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 11 Jul 2026 17:59:00 -0400 Subject: [PATCH 1/3] feat(frontend): add next-race preview page (#25) Assemble /preview from existing schedule, track-outline, results, and championship hub endpoints with client-side preview.ts helpers, section loading/empty states, and Vitest coverage for lib + page render paths. Co-authored-by: Cursor --- frontend/src/api.ts | 34 +- frontend/src/components/Nav.tsx | 3 + frontend/src/lib/preview.ts | 123 ++++++ frontend/src/pages/RacePreviewPage.tsx | 442 +++++++++++++++++++++ frontend/src/router.tsx | 8 + frontend/src/styles/preview.css | 309 ++++++++++++++ frontend/src/test/RacePreviewPage.test.tsx | 276 +++++++++++++ frontend/src/test/preview.test.ts | 226 +++++++++++ frontend/src/types.ts | 2 + 9 files changed, 1422 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/preview.ts create mode 100644 frontend/src/pages/RacePreviewPage.tsx create mode 100644 frontend/src/styles/preview.css create mode 100644 frontend/src/test/RacePreviewPage.test.tsx create mode 100644 frontend/src/test/preview.test.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1b7d972..7baae46 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,8 @@ import type { ArticleContent, CarDataSample, ChampionshipHub, + EnrichedGrid, + EnrichedResult, LapsComparisonResponse, LiveStateResponse, LiveSessionMeta, @@ -40,7 +42,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}`) } @@ -48,6 +54,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 a0227d3..052ec80 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -6,6 +6,7 @@ import { DataLibraryPage } from './pages/DataLibraryPage' import { LiveTimingPage } from './pages/LiveTimingPage' import { BriefingPage } from './pages/BriefingPage' import { ChampionshipPage } from './pages/ChampionshipPage' +import { RacePreviewPage } from './pages/RacePreviewPage' type RaceHubSearch = { session_key?: number @@ -70,6 +71,12 @@ export const briefingRoute = createRoute({ component: BriefingPage, }) +export const previewRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preview', + component: RacePreviewPage, +}) + const routeTree = rootRoute.addChildren([ commandCenterRoute, raceHubRoute, @@ -78,6 +85,7 @@ const routeTree = rootRoute.addChildren([ liveTimingRoute, championshipRoute, 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/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 c0b2fcf..22befb4 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 From 7212eb9b44ec724e0a1b60fbdb2fe2b7a30d91ce Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 11 Jul 2026 18:04:44 -0400 Subject: [PATCH 2/3] feat: detect race replay chapters Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload. Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy. --- frontend/src/test/CommandCenterPage.test.tsx | 1 + frontend/src/test/RaceHubPage.test.tsx | 1 + frontend/src/types.ts | 11 + internal/chapters/chapters.go | 628 +++++++++++++++++++ internal/chapters/chapters_test.go | 218 +++++++ internal/query/racehub.go | 19 + internal/web/racehub.go | 2 + internal/web/racehub_test.go | 60 ++ 8 files changed, 940 insertions(+) create mode 100644 internal/chapters/chapters.go create mode 100644 internal/chapters/chapters_test.go 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/types.ts b/frontend/src/types.ts index c0b2fcf..5fe5c56 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -89,6 +89,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) From 3f022a0cf931038915dcf7c63fa1eb1874721ee1 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 11 Jul 2026 18:10:34 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(agents):=20quote=20skill=20YAML=20descr?= =?UTF-8?q?iptions=20=E2=80=94=20strict=20parsers=20(cursor=20CLI)=20fail?= =?UTF-8?q?=20on=20embedded=20quotes/colons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .agents/skills/groom/SKILL.md | 2 +- .agents/skills/implement/SKILL.md | 2 +- .agents/skills/lens-architect/SKILL.md | 2 +- .agents/skills/review/SKILL.md | 2 +- .agents/skills/write-spec/SKILL.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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: ---