From 5408a45bbd692214244c26ef4645af81d20d5fbd Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 12:43:45 -0400 Subject: [PATCH] Refresh live sprint qualifying timing --- .../src/components/live/SessionBanner.tsx | 26 ++- frontend/src/components/live/TimingTower.tsx | 59 +++---- frontend/src/lib/battles.ts | 3 +- frontend/src/lib/live.ts | 153 ++++++++++++++++++ frontend/src/pages/LiveTimingPage.tsx | 20 ++- frontend/src/styles/app.css | 142 ++++++++++++++-- frontend/src/test/LiveComponents.test.tsx | 22 +++ frontend/src/test/battles.test.ts | 1 + frontend/src/test/live.test.ts | 136 +++++++++++++++- tests/live-timing.spec.ts | 76 ++++++++- 10 files changed, 585 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/live/SessionBanner.tsx b/frontend/src/components/live/SessionBanner.tsx index 7426b61..dbb58d5 100644 --- a/frontend/src/components/live/SessionBanner.tsx +++ b/frontend/src/components/live/SessionBanner.tsx @@ -1,17 +1,22 @@ import type { LiveStreamData } from '../../types' -import { extrapolateClock } from '../../lib/live' +import type { LiveTimingRow } from '../../lib/live' +import { extrapolateClock, liveSessionDisplay } from '../../lib/live' import { WeatherStrip } from './WeatherStrip' interface Props { isLive: boolean snapshot: LiveStreamData + rows: LiveTimingRow[] connection: 'connected' | 'connecting' | 'disconnected' | 'error' now: number } -export function SessionBanner({ isLive, snapshot, connection, now }: Props) { +export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) { const session = snapshot.Session const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) + const display = liveSessionDisplay(session, rows) + const atRiskLabel = + display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : '' return (
@@ -25,12 +30,17 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) {

-
- - L{snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} - - {clock && {clock}} - {isLive ? 'live' : 'stale'} +
+ {display.phaseLabel && {display.phaseLabel}} +
{clock || '--:--:--'}
+
+ {display.advanceCount && {display.advanceCount} advance} + {atRiskLabel && {atRiskLabel}} + + L{snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} + + {isLive ? 'live' : 'stale'} +
diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx index 8c6732d..b20810b 100644 --- a/frontend/src/components/live/TimingTower.tsx +++ b/frontend/src/components/live/TimingTower.tsx @@ -1,10 +1,11 @@ import { useState, Fragment } from 'react' import { teamColor } from '../../utils' import { useAutoAnimate } from '@formkit/auto-animate/react' -import type { LiveStintData } from '../../types' +import type { LiveSessionMeta, LiveStintData } from '../../types' import type { LiveTimingRow } from '../../lib/live' import { driverCode, + liveSessionDisplay, positionDelta, positionDeltaClass, tyreClass, @@ -22,7 +23,7 @@ interface Props { battleNumbers?: Set pinned?: string[] onTogglePin?: (racingNumber: string) => void - sessionType?: string + session?: LiveSessionMeta } function posClass(pos: number): string { @@ -39,7 +40,7 @@ export function TimingTower({ battleNumbers, pinned, onTogglePin, - sessionType = '', + session, }: Props) { const [expandedRow, setExpandedRow] = useState(null) const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval') @@ -53,16 +54,14 @@ export function TimingTower({ ) } - const sType = sessionType.toLowerCase() - const isRace = sType.includes('race') || sType.includes('sprint') - const isQuali = sType.includes('qualifying') || sType.includes('practice') || !isRace - - const isQ1 = sType === 'qualifying 1' || sType.includes('q1') - const isQ2 = sType === 'qualifying 2' || sType.includes('q2') + const sessionDisplay = liveSessionDisplay(session, rows) + const isRace = sessionDisplay.isRace + const isQuali = sessionDisplay.isQualifying || !isRace + const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0) return (
- +
@@ -95,27 +94,20 @@ export function TimingTower({ const isPinned = pinned?.includes(row.RacingNumber) ?? false const inBattle = battleNumbers?.has(row.RacingNumber) ?? false const isExpanded = expandedRow === row.RacingNumber - - // Knockout zone border - let koClass = '' - if (isQuali && (isQ1 || isQ2)) { - if (isQ1 && row.Position === 15) koClass = 'ko-line-p15' - if (isQ2 && row.Position === 10) koClass = 'ko-line-p10' - } else if (isQuali) { - if (row.Position === 15) koClass = 'ko-line-p15' - if (row.Position === 10) koClass = 'ko-line-p10' - } + const isAtRisk = + Boolean(sessionDisplay.cutoffPosition && row.Position > sessionDisplay.cutoffPosition) || + driver.Cutoff + const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader - // Render micro sectors const renderSector = (idx: number) => { const sec = driver.Sectors?.[idx] if (!sec) return '-' - let sClass = 'mono' - if (sec.OverallFastest) sClass = 'mono txt-purple' - else if (sec.PersonalFastest) sClass = 'mono txt-green' - else if (sec.Value) sClass = 'mono txt-yellow' + let sClass = 'sector-time mono' + if (sec.OverallFastest) sClass += ' sector-overall txt-purple' + else if (sec.PersonalFastest) sClass += ' sector-personal txt-green' + else if (sec.Value) sClass += ' sector-active txt-yellow' return {sec.Value || '-'} } @@ -128,7 +120,8 @@ export function TimingTower({ driver.Retired ? 'retired' : '', inBattle ? 'battle-row' : '', isPinned ? 'pinned-row' : '', - koClass, + isAtRisk && !driver.KnockedOut ? 'danger-row' : '', + driver.OnFlyingLap ? 'flying-row' : '', 'interactive-row' ].filter(Boolean).join(' ')} onClick={() => setExpandedRow(isExpanded ? null : row.RacingNumber)} @@ -146,6 +139,7 @@ export function TimingTower({ {driver.Retired && RET} {driver.KnockedOut && KO} {driver.Cutoff && !driver.KnockedOut && CUT} + {!driver.Cutoff && isAtRisk && !driver.KnockedOut && RISK} {driver.OnFlyingLap && FL} @@ -188,7 +182,7 @@ export function TimingTower({ {isExpanded && ( - )} + {showCutoffAfter && ( + + + + )} ) })} diff --git a/frontend/src/lib/battles.ts b/frontend/src/lib/battles.ts index 2096f3b..97d389e 100644 --- a/frontend/src/lib/battles.ts +++ b/frontend/src/lib/battles.ts @@ -25,7 +25,8 @@ export interface Battle { export function isRaceSession(sessionType: string | null | undefined): boolean { if (!sessionType) return false const type = sessionType.toLowerCase() - return type.includes('race') || type.includes('sprint') + const isQualifying = type.includes('qualifying') || type.includes('shootout') || /\bsq\s*[123]\b/.test(type) + return !isQualifying && (type.includes('race') || /\bsprint\b/.test(type)) } function isEligible(row: LiveTimingRow): boolean { diff --git a/frontend/src/lib/live.ts b/frontend/src/lib/live.ts index c76251b..ab4be5d 100644 --- a/frontend/src/lib/live.ts +++ b/frontend/src/lib/live.ts @@ -2,6 +2,8 @@ import type { LiveDriverData, LiveDriverInfo, LiveRCMessage, + LiveSectorData, + LiveSessionMeta, LiveStateResponse, LiveStreamData, LiveTyreData, @@ -15,6 +17,27 @@ export interface LiveTimingRow { Tyre?: LiveTyreData } +export interface LiveSessionDisplay { + isRace: boolean + isQualifying: boolean + isSprintQualifying: boolean + phase: 1 | 2 | 3 | null + phaseLabel: string + cutoffPosition: number | null + advanceCount: number | null + atRiskStart: number | null + atRiskEnd: number | null +} + +export interface VisibleSectorEntry { + lap: number + lastLapTime: string + completed: boolean + sectors: LiveSectorData[] +} + +export type VisibleSectorState = Record + const TRACK_STATUS_LABELS: Record = { '1': 'GREEN', '2': 'YELLOW', @@ -111,6 +134,136 @@ export function sortLiveTimingRows(snapshot: LiveStreamData | null | undefined): })) } +export function liveSessionDisplay( + session: LiveSessionMeta | null | undefined, + rows: ReadonlyArray, +): LiveSessionDisplay { + const text = [session?.SessionName, session?.SessionType].filter(Boolean).join(' ').toLowerCase() + const isQualifying = + /\bs?q\s*[123]\b/i.test(text) || + text.includes('qualifying') || + text.includes('shootout') + const isSprintQualifying = + /\bsq\s*[123]\b/i.test(text) || + text.includes('sprint qualifying') || + text.includes('sprint shootout') + const isRace = !isQualifying && (text.includes('race') || /\bsprint\b/i.test(text)) + const phase = isQualifying ? explicitQualifyingPhase(text) ?? inferredQualifyingPhase(rows) : null + const prefix = isSprintQualifying ? 'SQ' : 'Q' + const cutoffPosition = qualifyingCutoffPosition(rows.length, phase) + const atRiskStart = cutoffPosition === null ? null : cutoffPosition + 1 + + return { + isRace, + isQualifying, + isSprintQualifying, + phase, + phaseLabel: phase ? `${prefix}${phase}` : isQualifying ? prefix : '', + cutoffPosition, + advanceCount: cutoffPosition, + atRiskStart, + atRiskEnd: cutoffPosition === null ? null : rows.length, + } +} + +function explicitQualifyingPhase(text: string): 1 | 2 | 3 | null { + const match = text.match(/\b(?:s?q|qualifying)\s*([123])\b/i) + if (!match) return null + const phase = Number(match[1]) + return phase === 1 || phase === 2 || phase === 3 ? phase : null +} + +function inferredQualifyingPhase(rows: ReadonlyArray): 1 | 2 | 3 { + if (rows.length === 0) return 1 + const knockedOut = rows.filter((row) => row.Driver.KnockedOut).length + if (knockedOut >= Math.max(0, rows.length - 10)) return 3 + if (knockedOut >= 5) return 2 + if (rows.length <= 10) return 3 + if (rows.length <= 18) return 2 + return 1 +} + +export function qualifyingCutoffPosition(totalRows: number, phase: 1 | 2 | 3 | null): number | null { + if (phase === 1 && totalRows > 5) return totalRows - 5 + if (phase === 2 && totalRows > 10) return 10 + return null +} + +function emptySector(): LiveSectorData { + return { Value: '', PersonalFastest: false, OverallFastest: false } +} + +function sectorHasValue(sector: LiveSectorData | undefined): boolean { + return Boolean(sector?.Value) +} + +function normalizeSectors(sectors: ReadonlyArray | undefined): LiveSectorData[] { + return [0, 1, 2].map((index) => sectors?.[index] ?? emptySector()) +} + +export function mergeVisibleSectors( + previous: VisibleSectorState, + rows: ReadonlyArray, +): VisibleSectorState { + const next: VisibleSectorState = {} + + for (const row of rows) { + const driver = row.Driver + const prior = previous[row.RacingNumber] + const incoming = normalizeSectors(driver.Sectors) + const hasIncoming = incoming.some(sectorHasValue) + const lap = driver.NumberOfLaps || prior?.lap || 0 + const lapAdvanced = Boolean(prior && driver.NumberOfLaps > prior.lap) + const lastLapChanged = Boolean( + prior && + driver.LastLapTime && + driver.LastLapTime !== prior.lastLapTime, + ) + + if ((lapAdvanced || lastLapChanged || prior?.completed) && !hasIncoming) { + continue + } + + if (!prior && !hasIncoming) continue + + const merged = lapAdvanced || lastLapChanged ? normalizeSectors(undefined) : normalizeSectors(prior?.sectors) + for (let index = 0; index < 3; index += 1) { + if (sectorHasValue(incoming[index])) { + merged[index] = incoming[index] + } + } + + const hasMerged = merged.some(sectorHasValue) + if (!hasMerged) continue + + next[row.RacingNumber] = { + lap, + lastLapTime: driver.LastLapTime || prior?.lastLapTime || '', + completed: sectorHasValue(merged[2]) || (!driver.OnFlyingLap && lastLapChanged), + sectors: merged, + } + } + + return next +} + +export function rowsWithVisibleSectors( + rows: ReadonlyArray, + visibleSectors: VisibleSectorState, +): LiveTimingRow[] { + return rows.map((row) => { + const sectors = visibleSectors[row.RacingNumber]?.sectors + if (!sectors) return row + return { + ...row, + Driver: { + ...row.Driver, + Sectors: sectors, + }, + } + }) +} + export function driverCode(row: LiveTimingRow): string { return row.Info?.Tla || row.RacingNumber } diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 1c0ca07..613be01 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -4,11 +4,14 @@ import { fetchLiveState } from '../api' import type { LiveStreamData } from '../types' import { loadPinnedDrivers, + mergeVisibleSectors, parseLiveStateEvent, + rowsWithVisibleSectors, savePinnedDrivers, sortLiveTimingRows, togglePin, } from '../lib/live' +import type { VisibleSectorState } from '../lib/live' import type { GapHistoryMap } from '../lib/gapHistory' import { recordGapSamples } from '../lib/gapHistory' import { battleNumbers, detectBattles } from '../lib/battles' @@ -29,6 +32,7 @@ export function LiveTimingPage() { const [now, setNow] = useState(Date.now()) const [gapHistory, setGapHistory] = useState({}) const [pinned, setPinned] = useState(() => loadPinnedDrivers()) + const [visibleSectors, setVisibleSectors] = useState({}) const { data, isLoading, isError, error } = useQuery({ queryKey: ['live-state'], @@ -83,7 +87,17 @@ export function LiveTimingPage() { } }, []) - const rows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) + const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot]) + + useEffect(() => { + if (rawRows.length === 0) { + setVisibleSectors({}) + return + } + setVisibleSectors((prev) => mergeVisibleSectors(prev, rawRows)) + }, [rawRows]) + + const rows = useMemo(() => rowsWithVisibleSectors(rawRows, visibleSectors), [rawRows, visibleSectors]) // One interval sample per received snapshot, ring-buffered per driver. useEffect(() => { @@ -143,7 +157,7 @@ export function LiveTimingPage() { {snapshot && ( <> - +
@@ -160,7 +174,7 @@ export function LiveTimingPage() { battleNumbers={inBattle} pinned={pinned} onTogglePin={handleTogglePin} - sessionType={snapshot.Session?.SessionType} + session={snapshot.Session} />
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 0c266bc..40962bc 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -539,12 +539,17 @@ a { color: inherit; text-decoration: none; } } /* ── Live timing ── */ -.live-page { max-width: 1120px; } +.live-page { max-width: 1320px; } .live-banner { - padding-bottom: var(--s4); - border-bottom: 1px solid var(--border); + padding: var(--s4) var(--s5); + border: 1px solid rgba(255, 255, 255, 0.08); + border-left: 3px solid var(--red); + border-radius: 8px; margin-bottom: var(--s5); + background: + linear-gradient(90deg, rgba(225, 6, 0, 0.12), transparent 28%), + rgba(255, 255, 255, 0.025); } .live-banner-row { @@ -562,7 +567,7 @@ a { color: inherit; text-decoration: none; } } .live-banner h1 { - font-size: 18px; + font-size: 24px; line-height: 1.2; } @@ -571,12 +576,51 @@ a { color: inherit; text-decoration: none; } font-size: 12px; } +.live-session-board { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--s4); + flex-wrap: wrap; + text-align: right; +} + +.live-clock { + min-width: 148px; + color: var(--text); + font-size: 32px; + font-weight: 800; + line-height: 1; + letter-spacing: 0; + text-align: right; +} + +.live-phase-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 54px; + height: 44px; + padding: 0 var(--s3); + border: 1px solid rgba(255, 214, 0, 0.36); + border-radius: 4px; + background: rgba(255, 214, 0, 0.14); + color: var(--yellow); + font-size: 18px; + font-weight: 900; +} + .live-banner-meta { display: flex; align-items: center; justify-content: flex-end; - gap: var(--s3); + gap: var(--s2); flex-wrap: wrap; + max-width: 280px; + color: var(--text-2); + font-size: 11px; + font-family: var(--f-mono); + text-transform: uppercase; } .live-weather-strip { @@ -608,7 +652,7 @@ a { color: inherit; text-decoration: none; } @media (min-width: 900px) { .live-columns { display: grid; - grid-template-columns: minmax(0, 1fr) 340px; + grid-template-columns: minmax(0, 1fr) 318px; gap: var(--s5); align-items: start; } @@ -669,7 +713,7 @@ a { color: inherit; text-decoration: none; } .live-tower { font-variant-numeric: tabular-nums; border-collapse: separate; - border-spacing: 0 4px; + border-spacing: 0 3px; } .live-tower th { border-bottom: none !important; @@ -699,12 +743,55 @@ a { color: inherit; text-decoration: none; } .live-tower .in-pit td { background: rgba(0, 80, 160, 0.2) !important; } .live-tower .pit-out td { background: rgba(57, 199, 58, 0.15) !important; } .live-tower .retired td { opacity: 0.5; filter: grayscale(80%); } +.live-tower .danger-row td { + background: rgba(225, 6, 0, 0.095); +} +.live-tower .danger-row td:first-child { + box-shadow: inset 3px 0 0 rgba(225, 6, 0, 0.78); +} +.live-tower .flying-row td { + background: rgba(194, 120, 255, 0.055); +} +.live-tower .flying-row td:first-child { + box-shadow: inset 3px 0 0 rgba(194, 120, 255, 0.68); +} +.live-tower .danger-row.flying-row td:first-child { + box-shadow: inset 3px 0 0 rgba(225, 6, 0, 0.78), inset 6px 0 0 rgba(194, 120, 255, 0.68); +} + +.cutoff-separator td { + padding: 6px var(--s3) !important; + background: transparent !important; + border-radius: 0 !important; + border-top: 1px dashed rgba(225, 6, 0, 0.78) !important; + border-bottom: none !important; + color: #ff8a8a; + font-size: 10px; + font-family: var(--f-mono); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.cutoff-separator span, +.cutoff-separator strong, +.cutoff-separator em { + margin-right: var(--s4); + font-style: normal; +} + +.cutoff-separator strong { + color: var(--text); +} + +.cutoff-separator em { + color: #ffb0b0; +} /* Race Control Panel & Animations */ .panel-glass { background: rgba(20, 20, 20, 0.6); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; + border-radius: 8px; padding: 16px; backdrop-filter: blur(12px); max-height: calc(100vh - 120px); @@ -789,6 +876,7 @@ a { color: inherit; text-decoration: none; } .badge-flying { background: rgba(194,120,255,.14); color: var(--purple); border: 1px solid rgba(194,120,255,.26); } .badge-knocked { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } .badge-cutoff { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); } +.badge-risk { background: rgba(225,6,0,.14); color: #ff8a8a; border: 1px solid rgba(225,6,0,.28); } .tyre-soft { background: var(--tyre-soft); color: #fff; } .tyre-medium { background: var(--tyre-medium); color: #111; } @@ -867,6 +955,24 @@ a { color: inherit; text-decoration: none; } .spark-cell { line-height: 0; } +.sector-time { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 58px; + padding: 2px 5px; + border-radius: 3px; +} +.sector-active { + background: rgba(255, 214, 0, 0.08); +} +.sector-personal { + background: rgba(57, 199, 58, 0.1); +} +.sector-overall { + background: rgba(194, 120, 255, 0.12); +} + /* ── Battle highlighting ── */ .live-tower tr.battle-row td { background: rgba(255,214,0,.045); } .live-tower tr.battle-row td:first-child { box-shadow: inset 2px 0 0 rgba(255,214,0,.55); } @@ -2971,11 +3077,27 @@ a { color: inherit; text-decoration: none; } align-items: flex-start; gap: var(--s3); } - .live-banner-meta { + .live-session-board { justify-content: flex-start; + text-align: left; width: 100%; } - .live-banner h1 { font-size: 16px; } + .live-clock { + min-width: 0; + font-size: 26px; + text-align: left; + } + .live-phase-pill { + min-width: 48px; + height: 38px; + font-size: 16px; + } + .live-banner-meta { + justify-content: flex-start; + max-width: none; + width: 100%; + } + .live-banner h1 { font-size: 19px; } .live-weather-strip { gap: var(--s3); } .live-rc-scroll { max-height: 220px; } diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx index 2997cdc..16fcd51 100644 --- a/frontend/src/test/LiveComponents.test.tsx +++ b/frontend/src/test/LiveComponents.test.tsx @@ -187,6 +187,28 @@ describe('TimingTower', () => { render() expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument() }) + + it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => { + const sprintRows = Array.from({ length: 22 }, (_, index) => + makeRow(String(index + 1), index + 1, `D${index + 1}`), + ) + render( + , + ) + + expect(screen.getByTestId('qualifying-cutoff')).toHaveTextContent('SQ1 cutoff') + expect(screen.getByTestId('qualifying-cutoff')).toHaveTextContent('P17 advance') + expect(screen.getByText('D18').closest('tr')).toHaveClass('danger-row') + expect(screen.getByText('D17').closest('tr')).not.toHaveClass('danger-row') + }) }) describe('PinnedDrivers', () => { diff --git a/frontend/src/test/battles.test.ts b/frontend/src/test/battles.test.ts index b956d87..f95c49a 100644 --- a/frontend/src/test/battles.test.ts +++ b/frontend/src/test/battles.test.ts @@ -39,6 +39,7 @@ describe('isRaceSession', () => { it('only treats race and sprint sessions as races', () => { expect(isRaceSession('Race')).toBe(true) expect(isRaceSession('Sprint')).toBe(true) + expect(isRaceSession('Sprint Qualifying')).toBe(false) expect(isRaceSession('Qualifying')).toBe(false) expect(isRaceSession('Practice')).toBe(false) expect(isRaceSession('')).toBe(false) diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts index e3a94bf..28b90ef 100644 --- a/frontend/src/test/live.test.ts +++ b/frontend/src/test/live.test.ts @@ -4,10 +4,13 @@ import { compoundLetter, extrapolateClock, latestRaceControl, + liveSessionDisplay, loadPinnedDrivers, + mergeVisibleSectors, positionDeltaClass, parseLiveStateEvent, rcFlagClass, + rowsWithVisibleSectors, savePinnedDrivers, sortLiveTimingRows, togglePin, @@ -17,7 +20,8 @@ import { tyreLabel, windDirectionLabel, } from '../lib/live' -import type { LiveStreamData } from '../types' +import type { LiveDriverData, LiveSectorData, LiveStreamData } from '../types' +import type { LiveTimingRow } from '../lib/live' const snapshot: LiveStreamData = { Drivers: { @@ -106,6 +110,51 @@ const snapshot: LiveStreamData = { Stints: {}, } +function sector(value: string, over: Partial = {}): LiveSectorData { + return { Value: value, PersonalFastest: false, OverallFastest: false, ...over } +} + +function timingRow( + number: string, + position: number, + driver: Partial = {}, +): LiveTimingRow { + return { + RacingNumber: number, + Position: position, + Driver: { + RacingNumber: number, + Position: position, + PrevPosition: position, + GapToLeader: '', + Interval: '', + LastLapTime: '', + LastLapPB: false, + LastLapOB: false, + BestLapTime: '', + BestLapPB: false, + BestLapOB: false, + BestLapNum: 0, + InPit: false, + PitOut: false, + Retired: false, + KnockedOut: false, + Cutoff: false, + OnFlyingLap: false, + NumberOfLaps: 0, + SpeedTrap: '', + Sectors: [], + ...driver, + }, + } +} + +function rows(count: number, knockedOut = 0): LiveTimingRow[] { + return Array.from({ length: count }, (_, index) => + timingRow(String(index + 1), index + 1, { KnockedOut: index >= count - knockedOut }), + ) +} + describe('live transforms', () => { it('parses live EventSource snapshots without changing PascalCase data', () => { const parsed = parseLiveStateEvent(JSON.stringify({ is_live: true, data: snapshot })) @@ -168,6 +217,91 @@ describe('track status mapping', () => { }) }) +describe('live qualifying display', () => { + it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => { + const display = liveSessionDisplay( + { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' }, + rows(22), + ) + expect(display.phaseLabel).toBe('SQ1') + expect(display.cutoffPosition).toBe(17) + expect(display.advanceCount).toBe(17) + expect(display.atRiskStart).toBe(18) + expect(display.atRiskEnd).toBe(22) + }) + + it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => { + const display = liveSessionDisplay( + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' }, + rows(20), + ) + expect(display.phaseLabel).toBe('Q1') + expect(display.cutoffPosition).toBe(15) + }) + + it('moves phase 2 cutoff after P10 once five cars are knocked out', () => { + const display = liveSessionDisplay( + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' }, + rows(20, 5), + ) + expect(display.phaseLabel).toBe('Q2') + expect(display.cutoffPosition).toBe(10) + }) + + it('shows no cutoff for race sessions or Q3', () => { + expect( + liveSessionDisplay( + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' }, + rows(20), + ).cutoffPosition, + ).toBeNull() + expect( + liveSessionDisplay( + { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' }, + rows(10), + ).cutoffPosition, + ).toBeNull() + }) +}) + +describe('visible sector display', () => { + it('holds S1 and S2 through temporary blanks while a flying lap is active', () => { + const first = [timingRow('4', 1, { + NumberOfLaps: 3, + OnFlyingLap: true, + Sectors: [sector('29.111'), sector('41.222'), sector('')], + })] + const held = mergeVisibleSectors({}, first) + const blank = [timingRow('4', 1, { + NumberOfLaps: 3, + OnFlyingLap: false, + Sectors: [sector(''), sector(''), sector('')], + })] + const next = mergeVisibleSectors(held, blank) + const visibleRows = rowsWithVisibleSectors(blank, next) + + expect(visibleRows[0].Driver.Sectors[0].Value).toBe('29.111') + expect(visibleRows[0].Driver.Sectors[1].Value).toBe('41.222') + }) + + it('clears held sectors after the lap completes and the feed goes blank', () => { + const first = mergeVisibleSectors({}, [timingRow('4', 1, { + NumberOfLaps: 3, + LastLapTime: '1:30.000', + OnFlyingLap: true, + Sectors: [sector('29.111'), sector('41.222'), sector('20.333')], + })]) + const next = mergeVisibleSectors(first, [timingRow('4', 1, { + NumberOfLaps: 4, + LastLapTime: '1:30.000', + OnFlyingLap: false, + Sectors: [sector(''), sector(''), sector('')], + })]) + + expect(next['4']).toBeUndefined() + }) +}) + describe('weather and stint helpers', () => { it('maps wind direction degrees to compass points', () => { expect(windDirectionLabel(0)).toBe('N') diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts index 2375923..05d54cc 100644 --- a/tests/live-timing.spec.ts +++ b/tests/live-timing.spec.ts @@ -102,6 +102,51 @@ const raceSnapshot = { }, } +const sprintQualifyingSnapshot = { + is_live: true, + data: { + ...raceSnapshot.data, + Drivers: Object.fromEntries( + Array.from({ length: 22 }, (_, index) => { + const num = String(index + 1) + return [ + num, + driver(num, index + 1, index === 0 ? '' : `+${(index * 0.123).toFixed(3)}`, index === 0 ? '' : `+${(index * 0.123).toFixed(3)}`, { + LastLapTime: index < 2 ? '1:29.273' : '', + BestLapTime: `1:${String(29 + Math.floor(index / 10)).padStart(2, '0')}.${String(273 + index).padStart(3, '0')}`, + NumberOfLaps: 4, + Sectors: index === 7 + ? [{ Value: '28.573', PersonalFastest: false, OverallFastest: false }, { Value: '', PersonalFastest: false, OverallFastest: false }, { Value: '', PersonalFastest: false, OverallFastest: false }] + : [], + OnFlyingLap: index === 7, + }), + ] + }), + ), + DriverInfo: Object.fromEntries( + Array.from({ length: 22 }, (_, index) => { + const num = String(index + 1) + return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')] + }), + ), + Tyres: Object.fromEntries( + Array.from({ length: 22 }, (_, index) => [String(index + 1), { Compound: 'MEDIUM', New: false, Age: index % 4 }]), + ), + Session: { + MeetingName: 'British Grand Prix', + CircuitName: 'Silverstone', + SessionType: 'Sprint Qualifying', + SessionName: 'Sprint Qualifying', + }, + TrackStatus: '1', + CurrentLap: 0, + TotalLaps: 0, + Clock: '00:02:11', + ClockRefTime: '2026-07-03T15:39:49Z', + ClockExtrapolating: false, + }, +} + test.describe('Live Timing (mocked snapshot)', () => { test.beforeEach(async ({ page }) => { await page.route('**/api/v1/live/state', (route) => @@ -148,21 +193,46 @@ test.describe('Live Timing (mocked snapshot)', () => { }) test('renders stint history for drivers that have stints', async ({ page }) => { + await page.locator('.live-tower tbody tr', { hasText: 'VER' }).click() await expect(page.getByTestId('stint-seq').first()).toBeVisible() }) - test('clicking a row pins the driver to the focus strip', async ({ page }) => { - await page.locator('.live-tower tbody tr', { hasText: 'VER' }).click() + test('clicking a pin button pins the driver to the focus strip', async ({ page }) => { + await page.locator('.live-tower tbody tr', { hasText: 'VER' }).locator('.pin-btn').click() const pinned = page.getByTestId('pinned-strip') await expect(pinned).toBeVisible() await expect(pinned).toContainText('VER') // Unpin restores the empty strip. - await page.locator('.live-tower tbody tr', { hasText: 'VER' }).click() + await page.locator('.live-tower tbody tr', { hasText: 'VER' }).locator('.pin-btn').click() await expect(page.getByTestId('pinned-strip')).toHaveCount(0) }) }) +test.describe('Live Timing (mocked Sprint Qualifying)', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(sprintQualifyingSnapshot) }), + ) + await page.route('**/api/v1/live/stream', (route) => + route.fulfill({ + contentType: 'text/event-stream', + body: 'event: heartbeat\ndata: {}\n\n', + }), + ) + await page.goto('/live') + }) + + test('shows SQ1 phase, large clock, and 22-car cutoff after P17', async ({ page }) => { + await expect(page.getByText('SQ1', { exact: true })).toBeVisible() + await expect(page.getByTestId('live-clock')).toContainText('00:02:11') + await expect(page.getByTestId('qualifying-cutoff')).toContainText('P17 advance') + await expect(page.getByTestId('qualifying-cutoff')).toContainText('P18-P22 at risk') + await expect(page.locator('.live-tower tbody tr', { hasText: 'D18' })).toHaveClass(/danger-row/) + await expect(page.locator('.live-tower tbody tr', { hasText: 'D8' })).toHaveClass(/flying-row/) + }) +}) + test.describe('Live Timing (no session)', () => { test('shows the empty state when the feed has no snapshot', async ({ page }) => { await page.goto('/live')
Pos
+
STINTS
@@ -210,6 +204,17 @@ export function TimingTower({
+ {sessionDisplay.phaseLabel || 'Q'} cutoff + P{sessionDisplay.cutoffPosition} advance + {sessionDisplay.atRiskStart && sessionDisplay.atRiskEnd && ( + P{sessionDisplay.atRiskStart}-P{sessionDisplay.atRiskEnd} at risk + )} +