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/docs/phase-2/19-21-20-race-story.png b/docs/phase-2/19-21-20-race-story.png new file mode 100644 index 0000000..0de52a2 Binary files /dev/null and b/docs/phase-2/19-21-20-race-story.png differ diff --git a/docs/phase-2/22-driver-profile.png b/docs/phase-2/22-driver-profile.png new file mode 100644 index 0000000..9f02a47 Binary files /dev/null and b/docs/phase-2/22-driver-profile.png differ diff --git a/docs/phase-2/23-teammate-h2h.png b/docs/phase-2/23-teammate-h2h.png new file mode 100644 index 0000000..c5fa5a2 Binary files /dev/null and b/docs/phase-2/23-teammate-h2h.png differ diff --git a/docs/phase-2/24-rivalry-compare.png b/docs/phase-2/24-rivalry-compare.png new file mode 100644 index 0000000..44ffdca Binary files /dev/null and b/docs/phase-2/24-rivalry-compare.png differ diff --git a/docs/phase-2/25-race-preview.png b/docs/phase-2/25-race-preview.png new file mode 100644 index 0000000..4dd92cb Binary files /dev/null and b/docs/phase-2/25-race-preview.png differ diff --git a/docs/phase-2/26-briefing-digest.png b/docs/phase-2/26-briefing-digest.png new file mode 100644 index 0000000..48cfa79 Binary files /dev/null and b/docs/phase-2/26-briefing-digest.png differ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1b7d972..d749b5f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,12 +2,16 @@ import type { ArticleContent, CarDataSample, ChampionshipHub, + DriverSummary, + EnrichedGrid, + EnrichedResult, LapsComparisonResponse, LiveStateResponse, LiveSessionMeta, Meeting, NewsItem, RaceHub, + ReplayFramesResponse, Session, TrackOutline, Weekend, @@ -40,7 +44,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 +56,47 @@ 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 fetchReplayFrames( + sessionKey: number, + intervalMs = 5000, +): Promise { + const params = new URLSearchParams({ + session_key: String(sessionKey), + interval_ms: String(intervalMs), + }) + const res = await fetch(`/api/v1/replay/frames?${params}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} + export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise { const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) if (!res.ok) { @@ -76,6 +125,19 @@ export async function fetchChampionshipHub(year?: number): Promise { + const params = new URLSearchParams({ driver_number: String(driverNumber) }) + if (year) params.set('year', String(year)) + const res = await fetch(`/api/v1/driver/summary?${params.toString()}`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} + export async function fetchLiveState(): Promise { const res = await fetch('/api/v1/live/state') if (!res.ok) { diff --git a/frontend/src/components/ChapterStrip.tsx b/frontend/src/components/ChapterStrip.tsx new file mode 100644 index 0000000..13b504e --- /dev/null +++ b/frontend/src/components/ChapterStrip.tsx @@ -0,0 +1,87 @@ +import type { Chapter } from '../types' +import { + activeChapterIndex, + chapterKindLabel, + chapterLapRange, + chapterStartScrub, +} from '../lib/chapters' +import '../styles/chapters.css' + +interface Props { + chapters: Chapter[] + scrubTime: number | null + tMin: number + tRange: number + tourActive: boolean + tourChapterIndex: number | null + onChapterClick: (index: number, scrub: number) => void + onTourToggle: () => void +} + +export function ChapterStrip({ + chapters, + scrubTime, + tMin, + tRange, + tourActive, + tourChapterIndex, + onChapterClick, + onTourToggle, +}: Props) { + if (chapters.length === 0) { + return ( +
+

No story chapters for this session.

+
+ ) + } + + const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange) + + return ( +
+
+ Race chapters +
+ +
+
+
+ {chapters.map((chapter, index) => { + const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1) + const isActive = tourActive + ? tourChapterIndex === index + : activeIndex === index + const headline = chapter.headline || chapter.title + + return ( + + ) + })} +
+
+ ) +} 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/components/PaddockBriefing.tsx b/frontend/src/components/PaddockBriefing.tsx index 83d9ee8..3f44aeb 100644 --- a/frontend/src/components/PaddockBriefing.tsx +++ b/frontend/src/components/PaddockBriefing.tsx @@ -1,7 +1,23 @@ +import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { fetchNews } from '../api' +import { + fetchChampionshipHub, + fetchNews, + fetchSeasonMeetings, + fetchSeasons, +} from '../api' +import { + activeDigestWindow, + gpWindows, + itemsForWindow, + sinceLastLabel, + tagColour, + tagItems, + topTags, +} from '../lib/digest' import { timeAgo } from '../utils' +import '../styles/digest.css' const SOURCE_DISPLAY: Record = { 'fia': 'FIA', @@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record = { } export function PaddockBriefing() { + const now = useMemo(() => new Date(), []) + const { data: news, isLoading, isError } = useQuery({ queryKey: ['news'], queryFn: () => fetchNews(100), staleTime: 60_000, }) + const seasonsQuery = useQuery({ + queryKey: ['seasons'], + queryFn: fetchSeasons, + }) + const latestSeason = seasonsQuery.data?.[0] ?? null + + const meetingsQuery = useQuery({ + queryKey: ['season-meetings', latestSeason], + queryFn: () => fetchSeasonMeetings(latestSeason!), + enabled: latestSeason != null, + }) + + const hubQuery = useQuery({ + queryKey: ['championship-hub', latestSeason], + queryFn: () => fetchChampionshipHub(latestSeason!), + enabled: latestSeason != null, + }) + + const meetings = meetingsQuery.data ?? [] + const hub = hubQuery.data + const tagged = useMemo( + () => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []), + [news, hub], + ) + + const windows = useMemo(() => gpWindows(meetings, now), [meetings, now]) + const activeWindow = useMemo( + () => activeDigestWindow(windows, meetings, now), + [windows, meetings, now], + ) + const sinceItems = useMemo( + () => itemsForWindow(tagged, activeWindow), + [tagged, activeWindow], + ) + const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems]) + const sinceLabel = sinceLastLabel(meetings, now) + const unreadCount = news?.filter((i) => !i.read_at).length ?? 0 - const preview = news?.slice(0, 5) ?? [] + const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5)) return (
@@ -37,6 +92,27 @@ export function PaddockBriefing() { + {meetings.length > 0 && ( +
+ Since {sinceLabel} + · + {sinceItems.length} items + {sinceTags.length > 0 && ( +
+ {sinceTags.map((tag) => ( + + {tag.label} + + ))} +
+ )} +
+ )} + {isLoading &&
loading…
} {isError &&
Failed to load briefing
} diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index 229ff49..df54579 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -1,6 +1,13 @@ -import { useState, useMemo, useRef, useCallback } from 'react' -import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types' +import { fetchReplayFrames, fetchTrackOutline } from '../api' +import { ReplayTrackMap } from './ReplayTrackMap' +import { ChapterStrip } from './ChapterStrip' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' +import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters' + +const CHAPTER_TOUR_MS = 90_000 interface Props { data: { @@ -12,28 +19,181 @@ interface Props { race_control?: any[] pit_stops?: any[] session?: Session + meeting?: Meeting + drivers?: Driver[] + chapters?: Chapter[] } } export function RaceStoryCanvas({ data }: Props) { - const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data + const { + results, + starting_grid: grid, + positions, + datasets, + race_control = [], + pit_stops = [], + laps = [], + session, + meeting, + drivers = [], + chapters = [], + } = data const hasPositions = datasets['positions']?.status === 'available' const [scrubTime, setScrubTime] = useState(null) const [hoverDriver, setHoverDriver] = useState(null) + const [mapOpen, setMapOpen] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [playbackSpeed, setPlaybackSpeed] = useState(10) + const [chapterTourActive, setChapterTourActive] = useState(false) + const [tourChapterIndex, setTourChapterIndex] = useState(null) const svgRef = useRef(null) + const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) // Position Evolution Chart Logic const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const hasChartData = hasPositions && allTimes.length > 0 + const chartTiming = useMemo(() => { + if (!hasChartData) return null + const tMin = new Date(allTimes[0]).getTime() + const tMax = new Date(allTimes[allTimes.length - 1]).getTime() + return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) } + }, [allTimes, hasChartData]) + const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0 + const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0) + + const replayQuery = useQuery({ + queryKey: ['replay-frames', session?.session_key, 5000], + queryFn: () => fetchReplayFrames(session!.session_key, 5000), + enabled: mapOpen && Boolean(session?.session_key), + }) + const outlineQuery = useQuery({ + queryKey: ['track-outline', circuitKey, outlineYear], + queryFn: () => fetchTrackOutline(circuitKey, outlineYear), + enabled: mapOpen && circuitKey > 0 && outlineYear > 0, + }) + + useEffect(() => { + if (!isPlaying || !chartTiming) return + + let frame = 0 + let last = performance.now() + const tick = (now: number) => { + const delta = now - last + last = now + setScrubTime((current) => { + const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange) + if (next >= 1) { + setIsPlaying(false) + } + return next + }) + frame = requestAnimationFrame(tick) + } + + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + }, [chartTiming, isPlaying, playbackSpeed]) + + const stopChapterTour = () => { + setChapterTourActive(false) + setTourChapterIndex(null) + } + + const jumpToChapter = (index: number, scrub: number) => { + setIsPlaying(false) + stopChapterTour() + setScrubTime(scrub) + } + + const toggleChapterTour = () => { + if (chapterTourActive) { + stopChapterTour() + return + } + if (!chartTiming || chapters.length === 0) return + setIsPlaying(false) + setChapterTourActive(true) + setTourChapterIndex(0) + const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0 + setScrubTime(startScrub) + const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS) + tourRef.current = { + chapterIndex: 0, + startedAt: performance.now(), + durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length, + startScrub, + endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub, + } + } + + useEffect(() => { + if (!chapterTourActive || !chartTiming || chapters.length === 0) return + + let frame = 0 + const tick = (now: number) => { + const state = tourRef.current + const elapsed = now - state.startedAt + const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1)) + const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress + setScrubTime(scrub) + setTourChapterIndex(state.chapterIndex) + + if (progress >= 1) { + const nextIndex = state.chapterIndex + 1 + if (nextIndex >= chapters.length) { + stopChapterTour() + return + } + const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS) + const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0 + const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub + tourRef.current = { + chapterIndex: nextIndex, + startedAt: now, + durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length, + startScrub, + endScrub, + } + } + frame = requestAnimationFrame(tick) + } + + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + }, [chapterTourActive, chartTiming, chapters]) + + useEffect(() => { + if (!chapterTourActive) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + stopChapterTour() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [chapterTourActive]) + + const replayTMs = useMemo(() => { + const replay = replayQuery.data + const frames = replay?.frames ?? [] + if (frames.length === 0) return 0 + const lastFrameT = frames[frames.length - 1].t + const progress = scrubTime ?? 0 + if (!chartTiming || !replay?.start_time) { + return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT))) + } + const replayStart = new Date(replay.start_time).getTime() + const chartTime = chartTiming.tMin + progress * chartTiming.tRange + return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart))) + }, [chartTiming, replayQuery.data, scrubTime]) let chartContent = null let displayResults = results - if (hasChartData) { - const tMin = new Date(allTimes[0]).getTime() - const tMax = new Date(allTimes[allTimes.length - 1]).getTime() - const tRange = Math.max(tMax - tMin, 1) + if (hasChartData && chartTiming) { + const { tMin, tRange } = chartTiming const byDriver = new Map>() for (const p of positions) { @@ -137,6 +297,8 @@ export function RaceStoryCanvas({ data }: Props) { } const handlePointerMove = (e: React.PointerEvent) => { + setIsPlaying(false) + stopChapterTour() if (!svgRef.current) return const rect = svgRef.current.getBoundingClientRect() const x = e.clientX - rect.left @@ -288,24 +450,91 @@ export function RaceStoryCanvas({ data }: Props) { height={plotH} fill="transparent" onPointerMove={handlePointerMove} - onPointerLeave={() => setScrubTime(null)} + onPointerLeave={() => { + if (!isPlaying) setScrubTime(null) + }} style={{ cursor: 'crosshair', touchAction: 'none' }} /> +
+ +
+ {[1, 10, 30].map((speed) => ( + + ))} +
+ +
) } return (
- {hasChartData ? ( - chartContent - ) : ( -
- Lap-by-lap positions not available. This session does not - have ingested position samples in /api/v1/race-hub. -
+ {hasChartData && chartTiming && chapters.length > 0 && ( + )} +
+
+ {hasChartData ? ( + chartContent + ) : ( +
+ Lap-by-lap positions not available. This session does not + have ingested position samples in /api/v1/race-hub. +
+ )} +
+ {mapOpen && ( +
+ {circuitKey > 0 && outlineYear > 0 ? ( + + ) : ( +
track identity unavailable for replay map
+ )} +
+ )} +
{displayResults.length > 0 && (
diff --git a/frontend/src/components/ReplayTrackMap.tsx b/frontend/src/components/ReplayTrackMap.tsx new file mode 100644 index 0000000..d3ec274 --- /dev/null +++ b/frontend/src/components/ReplayTrackMap.tsx @@ -0,0 +1,133 @@ +import { useMemo, useState } from 'react' +import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types' +import { buildOutlinePath } from '../lib/trackmap' +import { interpolateReplayCars, replayCarToSvg } from '../lib/replay' +import '../styles/replay-map.css' + +interface Props { + outline?: TrackOutline | null + replay?: ReplayFramesResponse | null + tMs: number + drivers: Driver[] + results: EnrichedResult[] + loading?: boolean + error?: boolean +} + +export function ReplayTrackMap({ + outline, + replay, + tMs, + drivers, + results, + loading = false, + error = false, +}: Props) { + const [pinned, setPinned] = useState(null) + const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline]) + const driverInfo = useMemo(() => { + const info = new Map() + for (const driver of drivers) { + info.set(String(driver.driver_number), { + label: driver.name_acronym || String(driver.driver_number), + color: normalizeColor(driver.team_colour), + }) + } + for (const result of results) { + const key = String(result.driver_number) + if (!info.has(key)) { + info.set(key, { + label: result.name_acronym || key, + color: normalizeColor(result.team_colour), + }) + } + } + return info + }, [drivers, results]) + + const cars = useMemo(() => { + if (!outline?.bounds || !replay?.frames?.length) return [] + const positions = interpolateReplayCars(replay.frames, tMs) + return Object.entries(positions) + .map(([number, car]) => ({ + number, + svg: replayCarToSvg(car, outline.bounds), + info: driverInfo.get(number), + })) + .sort((a, b) => Number(a.number) - Number(b.number)) + }, [driverInfo, outline?.bounds, replay?.frames, tMs]) + + if (loading) { + return ( +
+
loading replay GPS...
+
+ ) + } + + if (error) { + return ( +
+
replay GPS unavailable for this session
+
+ ) + } + + if (!outline || !outlinePath) { + return ( +
+
track outline unavailable for this session
+
+ ) + } + + if (!replay?.frames?.length || cars.length === 0) { + return ( +
+
historical GPS unavailable for this session
+
+ ) + } + + return ( +
+
+ + + + {cars.map((car) => { + const label = car.info?.label ?? car.number + const selected = pinned === car.number + return ( + setPinned(selected ? null : car.number)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setPinned(selected ? null : car.number) + } + }} + > + {label} + + {label} + + ) + })} + +
+
+ ) +} + +function normalizeColor(color: string | undefined): string { + const raw = color?.trim() + if (!raw) return '#777777' + return raw.startsWith('#') ? raw : `#${raw}` +} diff --git a/frontend/src/components/RivalryCompare.tsx b/frontend/src/components/RivalryCompare.tsx new file mode 100644 index 0000000..2d89d80 --- /dev/null +++ b/frontend/src/components/RivalryCompare.tsx @@ -0,0 +1,271 @@ +import { useMemo, useState } from 'react' +import type { ChampHubDriver, ChampionshipHub } from '../types' +import { teamColor } from '../utils' +import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry' +import '../styles/rivalry.css' + +const PAD_L = 48 +const PAD_T = 16 +const PLOT_W = 882 +const PLOT_H = 316 +const GAP_PLOT_H = 200 + +function fmtPts(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(1) +} + +/** Up to ~7 evenly spaced round labels, always including the last round. */ +function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] { + const step = Math.max(1, Math.ceil(n / 7)) + const ticks: { x: number; label: string }[] = [] + for (let i = 0; i < n; i += step) { + ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` }) + } + const last = labels[n - 1] ?? `R${n}` + if (ticks[ticks.length - 1]?.label !== last) { + ticks.push({ x: x(n - 1), label: last }) + } + return ticks +} + +function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string { + return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ') +} + +interface PickerProps { + id: 'a' | 'b' + label: string + drivers: ChampHubDriver[] + value: number + color: string + onChange: (driverNumber: number) => void +} + +function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) { + return ( + + ) +} + +export function RivalryCompare({ hub }: { hub: ChampionshipHub }) { + const { drivers } = hub + const [pickA, setPickA] = useState(null) + const [pickB, setPickB] = useState(null) + + // Default to the top two in the standings; fall back there if a picked + // driver disappears (e.g. season switch re-fetches the hub). + const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0] + const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1] + + const tally = useMemo( + () => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null), + [a, b, hub.round_labels], + ) + + if (drivers.length < 2) { + return ( +
+ Need at least two drivers in the standings to compare a rivalry. +
+ ) + } + + const colorA = teamColor(a.team_colour) + const colorB = teamColor(b.team_colour) + const sameTeam = a.team_name === b.team_name + + const cumA = a.cumulative ?? [] + const cumB = b.cumulative ?? [] + const rounds = Math.min(cumA.length, cumB.length) + const gaps = gapSeries(cumA, cumB) + const lastGap = gaps[gaps.length - 1] ?? 0 + const strip = tally ? lastRounds(tally, 5) : [] + + const pickers = ( +
+ + vs + +
+ ) + + if (rounds === 0) { + return ( +
+ {pickers} +
+ No completed rounds yet — the rivalry will appear after the first race. +
+
+ ) + } + + // Points race scales. + const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1) + const maxY = Math.max(50, Math.ceil(peak / 50) * 50) + const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1)) + const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H + const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) })) + const pxGrid = xTicks(rounds, hub.round_labels, px) + + // Gap scales: symmetric around zero. + const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10) + const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2) + const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) })) + const gxGrid = xTicks(rounds, hub.round_labels, px) + + const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b + const gapCaption = gapLeader + ? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.` + : 'Dead level on points.' + + return ( +
+ {pickers} + +
+
+ + {a.name_acronym} + + + {tally ? `${tally.a}–${tally.b}` : '—'} + + + {b.name_acronym} + +
+ + Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted + {tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''} + + {strip.length > 0 && ( +
+ Last {strip.length} + {strip.map((r) => ( + + {r.label} + + {r.winner === 'a' ? a.name_acronym : b.name_acronym} + + + ))} +
+ )} +
+ +
+
+ + Points race — {a.name_acronym} vs {b.name_acronym} + + + Rounds 1–{rounds} · {hub.season} + +
+
+ + {pyGrid.map((g) => ( + + + + {g.label} + + + ))} + {pxGrid.map((g, i) => ( + + {g.label} + + ))} + {[ + { slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) }, + { slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) }, + ].map((s) => ( + + + + + {s.d.name_acronym} + + + ))} + +
+
+ +
+
+ + Gap over season — {a.name_acronym} − {b.name_acronym} + + {gapCaption} +
+
+ + {gyGrid.map((g) => ( + + + + {g.label} + + + ))} + {gxGrid.map((g, i) => ( + + {g.label} + + ))} + + + +
+

+ Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead. +

+
+
+ ) +} diff --git a/frontend/src/components/TeammateH2H.tsx b/frontend/src/components/TeammateH2H.tsx new file mode 100644 index 0000000..fd4f77a --- /dev/null +++ b/frontend/src/components/TeammateH2H.tsx @@ -0,0 +1,55 @@ +import { teamColor } from '../utils' +import '../styles/h2h.css' + +export interface TeammateH2HProps { + teamName: string + teamColour: string + driverATla: string + driverBTla: string + winsA: number + winsB: number + /** e.g. "+1" when more than two drivers share the team. */ + extraNote?: string +} + +export function TeammateH2H({ + teamName, + teamColour, + driverATla, + driverBTla, + winsA, + winsB, + extraNote, +}: TeammateH2HProps) { + const total = winsA + winsB || 1 + const pctA = (winsA / total) * 100 + const color = teamColor(teamColour) + + return ( +
+
+ {teamName} + {extraNote ? {extraNote} : null} +
+
+ {driverATla} +
+ + + + {winsA}–{winsB} + +
+ {driverBTla} +
+
+ ) +} diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts new file mode 100644 index 0000000..2ced668 --- /dev/null +++ b/frontend/src/lib/chapters.ts @@ -0,0 +1,82 @@ +import type { Chapter } from '../types' + +export function chapterKindLabel(kind: string): string { + switch (kind) { + case 'start': + return 'GO' + case 'safety_car': + return 'SC' + case 'virtual_safety_car': + return 'VSC' + case 'red_flag': + return 'RF' + case 'pit_phase': + return 'PIT' + case 'decisive_swing': + return '▲' + case 'finish': + return 'FIN' + default: + return kind.slice(0, 3).toUpperCase() + } +} + +export function chapterLapRange(chapter: Chapter): string { + if (chapter.start_lap === chapter.end_lap) { + return `L${chapter.start_lap}` + } + return `L${chapter.start_lap}–L${chapter.end_lap}` +} + +/** Normalized scrub position (0–1) for a chapter's start time on the chart axis. */ +export function chapterStartScrub( + chapter: Chapter, + tMin: number, + tRange: number, +): number | null { + if (!chapter.start_time || tRange <= 0) return null + const ms = new Date(chapter.start_time).getTime() + if (Number.isNaN(ms)) return null + return Math.max(0, Math.min(1, (ms - tMin) / tRange)) +} + +/** Normalized scrub position (0–1) for a chapter's end time on the chart axis. */ +export function chapterEndScrub( + chapter: Chapter, + tMin: number, + tRange: number, +): number | null { + const raw = chapter.end_time ?? chapter.start_time + if (!raw || tRange <= 0) return null + const ms = new Date(raw).getTime() + if (Number.isNaN(ms)) return null + return Math.max(0, Math.min(1, (ms - tMin) / tRange)) +} + +/** Index of the chapter containing the current scrub position, if any. */ +export function activeChapterIndex( + chapters: Chapter[], + scrubTime: number | null, + tMin: number, + tRange: number, +): number | null { + if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null + const chartMs = tMin + scrubTime * tRange + for (let i = 0; i < chapters.length; i++) { + const ch = chapters[i] + const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN + const endRaw = ch.end_time ?? ch.start_time + const endMs = endRaw ? new Date(endRaw).getTime() : NaN + if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) { + return i + } + } + return null +} + +/** Duration in ms each chapter should play during a ~90s tour. */ +export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] { + if (chapters.length === 0) return [] + const perChapter = totalMs / chapters.length + return chapters.map(() => perChapter) +} diff --git a/frontend/src/lib/digest.ts b/frontend/src/lib/digest.ts new file mode 100644 index 0000000..ec01629 --- /dev/null +++ b/frontend/src/lib/digest.ts @@ -0,0 +1,293 @@ +import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule' +import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types' + +export interface GPWindow { + meeting_key: number + meeting_name: string + /** Inclusive lower bound: end of previous GP, or open for season opener. */ + start: Date | null + /** Exclusive upper bound: start of next GP, or open after the final round. */ + end: Date | null +} + +export interface DigestTag { + kind: 'driver' | 'team' + key: string + label: string + colour: string +} + +export type TaggedNewsItem = NewsItem & { tags: DigestTag[] } + +export const RECENT_BUCKET_KEY = '__recent__' + +interface MatchCandidate { + start: number + end: number + tag: DigestTag +} + +interface TagPattern { + pattern: string + tag: DigestTag +} + +function sortMeetings(meetings: Meeting[]): Meeting[] { + return [...meetings].sort((a, b) => { + const left = meetingStartTime(a)?.getTime() ?? 0 + const right = meetingStartTime(b)?.getTime() ?? 0 + if (left !== right) return left - right + return a.meeting_name.localeCompare(b.meeting_name) + }) +} + +/** Derive inter-race windows [prev GP end → next GP start] for each round. */ +export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] { + const sorted = sortMeetings(meetings) + return sorted.map((meeting, index) => ({ + meeting_key: meeting.meeting_key, + meeting_name: meeting.meeting_name, + start: index > 0 ? meetingEndTime(sorted[index - 1]) : null, + end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null, + })) +} + +function itemPublishedAt(item: NewsItem): Date | null { + if (!item.published_at) return null + const parsed = Date.parse(item.published_at) + return Number.isNaN(parsed) ? null : new Date(parsed) +} + +function itemInWindow(published: Date, window: GPWindow): boolean { + if (window.start && published < window.start) return false + if (window.end && published >= window.end) return false + return true +} + +export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null { + const matches = windows.filter((window) => itemInWindow(published, window)) + if (matches.length === 0) return null + return matches.reduce((best, window) => { + const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY + const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY + return windowStart >= bestStart ? window : best + }) +} + +export interface GroupedDigest { + windows: Array<{ window: GPWindow; items: NewsItem[] }> + recent: NewsItem[] +} + +/** Bucket news items into GP windows; undated items land in `recent`. */ +export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest { + const buckets = new Map() + const recent: NewsItem[] = [] + + for (const item of items) { + const published = itemPublishedAt(item) + if (!published) { + recent.push(item) + continue + } + const match = windowForDate(windows, published) + if (!match) { + recent.push(item) + continue + } + const list = buckets.get(match.meeting_key) ?? [] + list.push(item) + buckets.set(match.meeting_key, list) + } + + const grouped = windows + .map((window) => ({ + window, + items: buckets.get(window.meeting_key) ?? [], + })) + .filter((entry) => entry.items.length > 0) + + return { windows: grouped, recent } +} + +function driverLastName(fullName: string): string | null { + const parts = fullName.trim().split(/\s+/).filter(Boolean) + if (parts.length < 2) return null + return parts[parts.length - 1] ?? null +} + +function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] { + const patterns: TagPattern[] = [] + + for (const team of teams) { + if (!team.team_name.trim()) continue + patterns.push({ + pattern: team.team_name.toLowerCase(), + tag: { + kind: 'team', + key: `team:${team.team_name}`, + label: team.team_name, + colour: team.team_colour, + }, + }) + } + + for (const driver of drivers) { + const colour = driver.team_colour + const base = { + kind: 'driver' as const, + colour, + } + if (driver.full_name.trim()) { + patterns.push({ + pattern: driver.full_name.toLowerCase(), + tag: { + ...base, + key: `driver:${driver.name_acronym}`, + label: driver.name_acronym, + }, + }) + const lastName = driverLastName(driver.full_name) + if (lastName) { + patterns.push({ + pattern: lastName.toLowerCase(), + tag: { + ...base, + key: `driver:${driver.name_acronym}`, + label: driver.name_acronym, + }, + }) + } + } + if (driver.name_acronym.trim()) { + patterns.push({ + pattern: driver.name_acronym.toLowerCase(), + tag: { + ...base, + key: `driver:${driver.name_acronym}`, + label: driver.name_acronym, + }, + }) + } + } + + patterns.sort((a, b) => b.pattern.length - a.pattern.length) + return patterns +} + +function overlaps(a: MatchCandidate, b: MatchCandidate): boolean { + return a.start < b.end && b.start < a.end +} + +/** + * Tag items via case-insensitive substring match on title + summary. + * Longest pattern wins on overlapping spans (v1 string match — no NLP). + * + * Known limitations: common-word last names, team names inside other words, + * and driver TLAs that appear in unrelated acronyms can false-positive. + */ +export function tagItems( + items: NewsItem[], + drivers: ChampHubDriver[], + teams: ChampHubTeam[], +): TaggedNewsItem[] { + const patterns = buildTagPatterns(drivers, teams) + + return items.map((item) => { + const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase() + const accepted: MatchCandidate[] = [] + + for (const { pattern, tag } of patterns) { + if (!pattern) continue + let from = 0 + while (from <= haystack.length - pattern.length) { + const index = haystack.indexOf(pattern, from) + if (index === -1) break + const candidate: MatchCandidate = { + start: index, + end: index + pattern.length, + tag, + } + if (!accepted.some((match) => overlaps(match, candidate))) { + accepted.push(candidate) + } + from = index + 1 + } + } + + const tagsByKey = new Map() + for (const match of accepted) { + tagsByKey.set(match.tag.key, match.tag) + } + + return { + ...item, + tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)), + } + }) +} + +export function activeDigestWindow( + windows: GPWindow[], + meetings: Meeting[], + now: Date, +): GPWindow | null { + const next = nextUpcomingMeeting(meetings, now) + if (next) { + return windows.find((window) => window.meeting_key === next.meeting_key) ?? null + } + const recent = mostRecentPastMeeting(meetings, now) + if (recent) { + return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null + } + return windows[0] ?? null +} + +export function sinceLastLabel(meetings: Meeting[], now: Date): string { + const last = mostRecentPastMeeting(meetings, now) + if (last) return last.meeting_name + const next = nextUpcomingMeeting(meetings, now) + if (next) return `Before ${next.meeting_name}` + return 'Pre-season' +} + +export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] { + if (!window) return [] + return items.filter((item) => { + const published = itemPublishedAt(item) + return published != null && itemInWindow(published, window) + }) +} + +export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] { + const counts = new Map() + for (const item of items) { + for (const tag of item.tags) { + const current = counts.get(tag.key) + if (current) current.count += 1 + else counts.set(tag.key, { tag, count: 1 }) + } + } + return [...counts.values()] + .sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label)) + .slice(0, limit) + .map((entry) => entry.tag) +} + +export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] { + if (!tagKey) return items + return items.filter((item) => item.tags.some((tag) => tag.key === tagKey)) +} + +export function tagColour(colour: string): string { + if (!colour) return 'var(--text-3)' + return colour.startsWith('#') ? colour : `#${colour}` +} + +export function sortWindowBucketsNewestFirst(buckets: T[]): T[] { + return [...buckets].sort((a, b) => { + const left = a.window.start?.getTime() ?? 0 + const right = b.window.start?.getTime() ?? 0 + return right - left + }) +} diff --git a/frontend/src/lib/driverProfile.ts b/frontend/src/lib/driverProfile.ts new file mode 100644 index 0000000..4b08690 --- /dev/null +++ b/frontend/src/lib/driverProfile.ts @@ -0,0 +1,69 @@ +import type { DriverSummaryRound } from '../types' + +/** One round mapped for the grid-vs-finish chart and results table. */ +export interface RoundDelta { + round: number // 1-based round index within the completed season + label: string // short GP label, e.g. "Bahrain" + grid: number | null + finish: number | null + /** grid − finish: positive = places gained on Sunday, null when unknowable. */ + delta: number | null + points: number + status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent' +} + +/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */ +export function shortGpLabel(meetingName: string): string { + const short = meetingName + .replace(/\s*grand prix\s*/i, ' ') + .replace(/\s+gp\s*$/i, ' ') + .trim() + return short || meetingName +} + +function roundStatus(r: DriverSummaryRound): RoundDelta['status'] { + if (r.dns) return 'dns' + if (r.dsq) return 'dsq' + if (r.dnf) return 'dnf' + if (r.race_position <= 0 && r.grid_position <= 0) return 'absent' + return 'classified' +} + +/** + * Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data" + * (driver absent, pit-lane start, …) and become null; a delta is only computed + * when both ends are known and the driver actually started the race. + */ +export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] { + return rounds.map((r, i) => { + const status = roundStatus(r) + const grid = r.grid_position > 0 ? r.grid_position : null + const finish = r.race_position > 0 ? r.race_position : null + const delta = + grid != null && finish != null && status !== 'dns' && status !== 'absent' + ? grid - finish + : null + return { + round: i + 1, + label: shortGpLabel(r.meeting_name), + grid, + finish, + delta, + points: r.points, + status, + } + }) +} + +/** 'P4' for known positions, '—' when unknown. */ +export function formatPosition(pos: number | null): string { + return pos != null && pos > 0 ? `P${pos}` : '—' +} + +/** '+3' (gained), '−2' (lost), '=' (held), '—' (unknown). */ +export function formatDelta(delta: number | null): string { + if (delta == null) return '—' + if (delta > 0) return `+${delta}` + if (delta < 0) return `−${Math.abs(delta)}` + return '=' +} diff --git a/frontend/src/lib/gpIdentity.ts b/frontend/src/lib/gpIdentity.ts index 24cca81..f1dcbeb 100644 --- a/frontend/src/lib/gpIdentity.ts +++ b/frontend/src/lib/gpIdentity.ts @@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string { return name.slice(0, 3).toUpperCase() || '—' } -export function countryFlag(meeting: Meeting | undefined | null): string { +export function countryFlag( + meeting: Pick | undefined | null, +): string { if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag const code = meeting?.country_code?.toUpperCase() const iso2 = code ? CODE3_TO_2[code] : '' diff --git a/frontend/src/lib/h2h.ts b/frontend/src/lib/h2h.ts new file mode 100644 index 0000000..0b5defc --- /dev/null +++ b/frontend/src/lib/h2h.ts @@ -0,0 +1,47 @@ +import type { ChampHubDriver } from '../types' + +export interface TeammatePair { + teamName: string + teamColour: string + driverA: ChampHubDriver + driverB: ChampHubDriver + /** Drivers on the team beyond the top two (shown as +N). */ + extraCount: number + /** Absolute win gap — smaller means a closer battle. */ + closeness: number +} + +/** Pair teammates per team for H2H display; skip single-driver teams. */ +export function teammatePairs(drivers: ReadonlyArray): TeammatePair[] { + const byTeam = new Map() + for (const d of drivers) { + const list = byTeam.get(d.team_name) ?? [] + list.push(d) + byTeam.set(d.team_name, list) + } + + const pairs: TeammatePair[] = [] + for (const [teamName, members] of byTeam) { + if (members.length < 2) continue + + const sorted = [...members].sort((a, b) => b.points - a.points) + const driverA = sorted[0] + const driverB = sorted[1] + const winsA = driverA.teammate_wins + const winsB = driverB.teammate_wins + + pairs.push({ + teamName, + teamColour: driverA.team_colour, + driverA, + driverB, + extraCount: members.length - 2, + closeness: Math.abs(winsA - winsB), + }) + } + + return pairs.sort((a, b) => { + if (a.closeness !== b.closeness) return a.closeness - b.closeness + return a.teamName.localeCompare(b.teamName) + }) +} 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/lib/replay.ts b/frontend/src/lib/replay.ts new file mode 100644 index 0000000..5699016 --- /dev/null +++ b/frontend/src/lib/replay.ts @@ -0,0 +1,75 @@ +import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types' +import { canvasToSvg, normalizeRawPoint } from './trackmap' + +export interface ReplayFramePair { + previous: ReplayFrame | null + next: ReplayFrame | null +} + +export function lookupReplayFramePair( + frames: ReadonlyArray, + tMs: number, +): ReplayFramePair { + if (frames.length === 0) return { previous: null, next: null } + if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] } + const last = frames[frames.length - 1] + if (tMs >= last.t) return { previous: last, next: last } + + let lo = 0 + let hi = frames.length - 1 + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2) + const frame = frames[mid] + if (frame.t === tMs) return { previous: frame, next: frame } + if (frame.t < tMs) { + lo = mid + 1 + } else { + hi = mid - 1 + } + } + + return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] } +} + +export function interpolateReplayCars( + frames: ReadonlyArray, + tMs: number, +): Record { + const { previous, next } = lookupReplayFramePair(frames, tMs) + if (!previous && !next) return {} + if (!previous) return next?.cars ?? {} + if (!next || previous.t === next.t) return previous.cars + + const span = next.t - previous.t + const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span) + const cars: Record = {} + const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)]) + + for (const number of numbers) { + const before = previous.cars[number] + const after = next.cars[number] + if (before && after) { + cars[number] = { + x: before.x + (after.x - before.x) * fraction, + y: before.y + (after.y - before.y) * fraction, + } + } else if (before) { + cars[number] = before + } else if (after) { + cars[number] = after + } + } + return cars +} + +export function replayCarToSvg( + car: ReplayCarPosition, + bounds: TrackBounds, +): ReplayCarPosition { + return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds)) +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(1, Math.max(0, value)) +} diff --git a/frontend/src/lib/rivalry.ts b/frontend/src/lib/rivalry.ts new file mode 100644 index 0000000..f5b4092 --- /dev/null +++ b/frontend/src/lib/rivalry.ts @@ -0,0 +1,61 @@ +// Pure helpers for the championship rivalry compare view. + +/** One round that counted toward the head-to-head tally. */ +export interface H2HRound { + round: number // 1-based season round + label: string + posA: number + posB: number + winner: 'a' | 'b' +} + +export interface H2HTally { + a: number + b: number + /** Rounds counted in the tally, in season order. */ + rounds: H2HRound[] + /** Rounds skipped because either driver had no finishing position. */ + skipped: number +} + +/** + * Per-round points gap (a − b) across the rounds both cumulative series + * cover. Positive values mean driver A is ahead. + */ +export function gapSeries(a: number[], b: number[]): number[] { + const n = Math.min(a.length, b.length) + const out: number[] = [] + for (let i = 0; i < n; i++) out.push(a[i] - b[i]) + return out +} + +/** + * Race head-to-head: per round, the lower finishing position wins. Rounds + * where either driver has no position (0 / missing) are skipped from the + * tally, as are equal positions (defensive — races can't tie). + */ +export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally { + const n = Math.max(posA.length, posB.length) + const rounds: H2HRound[] = [] + let a = 0 + let b = 0 + let skipped = 0 + for (let i = 0; i < n; i++) { + const pa = posA[i] ?? 0 + const pb = posB[i] ?? 0 + if (pa <= 0 || pb <= 0 || pa === pb) { + skipped++ + continue + } + const winner = pa < pb ? 'a' : 'b' + if (winner === 'a') a++ + else b++ + rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner }) + } + return { a, b, rounds, skipped } +} + +/** Last n counted rounds of a tally, for the mini strip. */ +export function lastRounds(tally: H2HTally, n: number): H2HRound[] { + return tally.rounds.slice(-n) +} diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx index a283f05..d0ceda3 100644 --- a/frontend/src/pages/BriefingPage.tsx +++ b/frontend/src/pages/BriefingPage.tsx @@ -1,13 +1,33 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { fetchNews, fetchNewsArticle, markNewsRead } from '../api' +import { + fetchChampionshipHub, + fetchNews, + fetchNewsArticle, + fetchSeasonMeetings, + fetchSeasons, + markNewsRead, +} from '../api' +import { + activeDigestWindow, + filterByTag, + groupByWindow, + gpWindows, + itemsForWindow, + sinceLastLabel, + sortWindowBucketsNewestFirst, + tagColour, + tagItems, + topTags, + type DigestTag, + type TaggedNewsItem, +} from '../lib/digest' import { stripHtml, timeAgo } from '../utils' import type { ArticleContent, NewsItem } from '../types' +import '../styles/digest.css' type Category = 'all' | 'official' | 'news' | 'video' -const PAGE_SIZE = 16 - const CATEGORY_LABELS: Record = { all: 'All', official: 'Official', @@ -71,6 +91,31 @@ function CategoryTabs({ ) } +function TagChip({ + tag, + active, + onClick, + className = 'digest-tag-chip', +}: { + tag: DigestTag + active?: boolean + onClick?: () => void + className?: string +}) { + return ( + + ) +} + function OGImage({ url, title }: { url?: string; title: string }) { const [failed, setFailed] = useState(false) const initial = (title[0] ?? '?').toUpperCase() @@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) { function BriefingCard({ item, isActive, + activeTag, onSelect, + onTagClick, }: { - item: NewsItem + item: TaggedNewsItem isActive: boolean + activeTag: string | null onSelect: (item: NewsItem) => void + onTagClick: (tag: DigestTag) => void }) { const isRead = !!item.read_at const isVideo = categoryOf(item) === 'video' @@ -129,6 +178,25 @@ function BriefingCard({ {stripHtml(item.og_description || item.summary || '')}

)} + {item.tags.length > 0 && ( +
+ {item.tags.map((tag) => ( + + ))} +
+ )}
) @@ -146,7 +214,6 @@ function ReaderPanel({ const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - // Close on Escape useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() @@ -155,7 +222,6 @@ function ReaderPanel({ return () => document.removeEventListener('keydown', handler) }, [onClose]) - // Fetch article when item changes useEffect(() => { if (!item) { setArticle(null) @@ -175,9 +241,14 @@ function ReaderPanel({ .catch((err) => { setError(String(err)); setLoading(false) }) }, [item?.url, item ? categoryOf(item) : '']) - // Scroll panel to top when item changes useEffect(() => { - panelRef.current?.scrollTo({ top: 0 }) + const panel = panelRef.current + if (!panel) return + if (typeof panel.scrollTo === 'function') { + panel.scrollTo({ top: 0 }) + } else { + panel.scrollTop = 0 + } }, [item?.url]) const isOpen = item !== null @@ -297,7 +368,6 @@ function ReaderPanel({ {article && !loading && article.content && (
@@ -332,8 +402,9 @@ function ReaderPanel({ export function BriefingPage() { const [activeCategory, setActiveCategory] = useState('all') const [selectedItem, setSelectedItem] = useState(null) - const [page, setPage] = useState(1) + const [activeTag, setActiveTag] = useState(null) const queryClient = useQueryClient() + const now = useMemo(() => new Date(), []) const { data: allNews = [], isLoading, isError } = useQuery({ queryKey: ['news'], @@ -341,13 +412,74 @@ export function BriefingPage() { staleTime: 60_000, }) - const filtered = allNews.filter( - (item) => activeCategory === 'all' || categoryOf(item) === activeCategory, + const seasonsQuery = useQuery({ + queryKey: ['seasons'], + queryFn: fetchSeasons, + }) + const latestSeason = seasonsQuery.data?.[0] ?? null + + const meetingsQuery = useQuery({ + queryKey: ['season-meetings', latestSeason], + queryFn: () => fetchSeasonMeetings(latestSeason!), + enabled: latestSeason != null, + }) + + const hubQuery = useQuery({ + queryKey: ['championship-hub', latestSeason], + queryFn: () => fetchChampionshipHub(latestSeason!), + enabled: latestSeason != null, + }) + + const meetings = meetingsQuery.data ?? [] + const hub = hubQuery.data + + const taggedNews = useMemo( + () => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []), + [allNews, hub], ) - const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) - const safePage = Math.min(page, pageCount) - const pageStart = (safePage - 1) * PAGE_SIZE - const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE) + + const categoryFiltered = useMemo( + () => taggedNews.filter( + (item) => activeCategory === 'all' || categoryOf(item) === activeCategory, + ), + [taggedNews, activeCategory], + ) + + const tagFiltered = useMemo( + () => filterByTag(categoryFiltered, activeTag), + [categoryFiltered, activeTag], + ) + + const windows = useMemo(() => gpWindows(meetings, now), [meetings, now]) + const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows]) + const windowSections = useMemo( + () => sortWindowBucketsNewestFirst(grouped.windows), + [grouped.windows], + ) + + const activeWindow = useMemo( + () => activeDigestWindow(windows, meetings, now), + [windows, meetings, now], + ) + + const sinceLastItems = useMemo( + () => itemsForWindow(tagFiltered, activeWindow), + [tagFiltered, activeWindow], + ) + + const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems]) + const sinceLabel = sinceLastLabel(meetings, now) + + const taggedByUrl = useMemo(() => { + const map = new Map() + for (const item of tagFiltered) map.set(item.url, item) + return map + }, [tagFiltered]) + + const recentTagged = useMemo(() => { + const urls = new Set(grouped.recent.map((item) => item.url)) + return tagFiltered.filter((item) => urls.has(item.url)) + }, [grouped.recent, tagFiltered]) const counts: Record = { all: allNews.length, @@ -374,22 +506,14 @@ export function BriefingPage() { const handleClose = useCallback(() => setSelectedItem(null), []) - useEffect(() => { - setPage((current) => Math.min(current, pageCount)) - }, [pageCount]) - - const handlePageChange = useCallback( - (nextPage: number) => { - setPage(Math.min(Math.max(nextPage, 1), pageCount)) - setSelectedItem(null) - window.scrollTo({ top: 0, behavior: 'smooth' }) - }, - [pageCount], - ) + const handleTagClick = useCallback((tag: DigestTag) => { + setActiveTag((current) => (current === tag.key ? null : tag.key)) + setSelectedItem(null) + }, []) const unreadCount = allNews.filter((i) => !i.read_at).length - const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length) - const showPagination = filtered.length > PAGE_SIZE + const hasDigest = meetings.length > 0 + const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0 return (
@@ -408,74 +532,106 @@ export function BriefingPage() { { setActiveCategory(c); setSelectedItem(null); setPage(1) }} + onChange={(c) => { + setActiveCategory(c) + setSelectedItem(null) + setActiveTag(null) + }} /> - {filtered.length === 0 ? ( + {hasDigest && ( +
+
+

Since {sinceLabel}

+ + {sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'} + +
+ {(sinceLastTags.length > 0 || activeTag) && ( +
+ {sinceLastTags.map((tag) => ( + handleTagClick(tag)} + /> + ))} + {activeTag && ( + + )} +
+ )} +
+ )} + + {showEmpty ? (
No {activeCategory !== 'all' ? activeCategory : ''} items available. - Run box-box --ingest-news to refresh feeds. + {activeTag ? ' Try clearing the tag filter.' : ''} + {!activeTag && ( + <> + {' '} + Run box-box --ingest-news to refresh feeds. + + )}
) : ( <> - {showPagination && ( -
- - {pageStart + 1}-{pageEnd} of {filtered.length} - -
- - - Page {safePage} / {pageCount} - - + {windowSections.map(({ window, items }) => ( +
+
+

{window.meeting_name}

+ {items.length}
-
- )} +
+ {items.map((item) => { + const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] } + return ( + + ) + })} +
+
+ ))} -
- {pagedItems.map((item) => ( - - ))} -
- - {showPagination && ( -
- - - Page {safePage} / {pageCount} - - -
+ {recentTagged.length > 0 && ( +
+
+

Recent

+ {recentTagged.length} +
+
+ {recentTagged.map((item) => ( + + ))} +
+
)} )} diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index c004eb1..7f09490 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -1,13 +1,17 @@ import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' import { fetchChampionshipHub, fetchSeasons } from '../api' import { teamColor } from '../utils' import type { ChampHubDriver, ChampionshipHub } from '../types' import { ChampionshipSimulator } from '../components/ChampionshipSimulator' +import { RivalryCompare } from '../components/RivalryCompare' import { Meaning } from '../components/Meaning' +import { TeammateH2H } from '../components/TeammateH2H' +import { teammatePairs } from '../lib/h2h' import { pointsGapMeaning } from '../lib/meaning' -type View = 'drivers' | 'constructors' | 'progression' | 'simulator' +type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator' const GOLD = '#ffd700' const SILVER = '#c0c0c0' @@ -213,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { > Progression +