From 8d7cf782cc7e260c4a9f7add0f68970c5acc479b Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sat, 28 Mar 2026 14:04:56 +1100 Subject: [PATCH 01/11] 1.3.2: High contrast text and UI layout fixes - High contrast text toggle in Settings > Other switches all muted/grey text to white via CSS variable, colour-coded elements unaffected - Settings modal height increased to 450px on desktop - Mobile leaderboard: tighter row padding, smaller position column, narrower last lap column, smaller auto-hidden badge - Practice/qualifying: added spacing between lap time and gap columns Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 +++++++++++- frontend/src/app/globals.css | 8 ++++++++ frontend/src/app/replay/[year]/[round]/page.tsx | 2 +- frontend/src/components/Leaderboard.tsx | 14 +++++++------- frontend/src/components/SessionBanner.tsx | 5 +++-- frontend/src/hooks/useSettings.ts | 11 ++++++++++- frontend/tailwind.config.ts | 2 +- 7 files changed, 41 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e30e5bd..91bb335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -All notable changes to F1 Timing Replay will be documented in this file. +All notable changes to F1 Replay Timing will be documented in this file. + +## 1.3.2 + +### Improvements +- **High contrast text** — toggle in Settings > Other to switch all muted/grey text to white for better readability, especially on lower-brightness screens. Colour-coded elements (intervals, penalties, tyre indicators) are unaffected + +### Fixes +- **Minor UI layout fixes** — improved spacing and alignment across mobile, tablet, and desktop screen sizes + +--- ## 1.3.1 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index be239d2..0a182e3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,6 +2,14 @@ @tailwind components; @tailwind utilities; +:root { + --f1-muted: #6B7280; +} + +.high-contrast { + --f1-muted: #FFFFFF; +} + html, body { margin: 0; diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index faa58c5..75d1b90 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -277,7 +277,7 @@ export default function ReplayPage() { if (isRace && settings.showGridChange) w += 24; if (!isRace && settings.showBestLapTime) w += 60; // best lap time column if (isRace && settings.showLastLapTime) w += 60; // last lap time column - if (settings.showGapToLeader) w += 56; + if (settings.showGapToLeader) w += 56 + (!isRace ? 8 : 0); // extra margin between lap time and gap in practice/qualifying if (isQualifying && settings.showSectors) w += 36; // sector indicators (28 + 8 margin) if (isRace && settings.showPitStops) w += 24; if (isRace && settings.showTyreHistory) w += 36; diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index 1a66141..d35f6dc 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -157,17 +157,17 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick - {showDelaySlider && ( -
-
- Broadcast Delay -
- setDelayOffset(Number(e.target.value))} - className="w-full h-1 bg-f1-border rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -60s - +10s -
-
- - { - const v = Number(e.target.value); - if (!isNaN(v)) setDelayOffset(Math.max(-60, Math.min(10, v))); - }} - className="w-16 px-1.5 py-0.5 bg-f1-dark border border-f1-border rounded text-[10px] text-white text-center focus:outline-none focus:border-blue-500" - /> - seconds + +
+ {/* Current value display */} +
+ {delayOffset.toFixed(1)} + seconds +
+ + {/* Slider */} + {/* Slider with zero tick mark */} +
+ setDelayOffset(Number(e.target.value))} + className="w-full h-1.5 bg-f1-border rounded-lg appearance-none cursor-pointer accent-blue-500 relative z-10" + /> + {/* Zero tick — positioned absolutely over the slider */} +
+
+
+
+
+ -60s + 0s + +10s +
+ + {/* Quick adjust buttons */} +
+ {[ + { label: "-5s", delta: -5 }, + { label: "-1s", delta: -1 }, + { label: "-0.5s", delta: -0.5 }, + ].map(({ label, delta }) => ( + + ))} + + {[ + { label: "+0.5s", delta: 0.5 }, + { label: "+1s", delta: 1 }, + { label: "+5s", delta: 5 }, + ].map(({ label, delta }) => ( + + ))} +
+ + {/* Manual input */} +
+ +
+ + { e.target.value = ""; }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const raw = (e.target as HTMLInputElement).value; + if (raw === "") return; + const v = Math.abs(Number(raw)); + if (isNaN(v)) return; + const sign = (document.getElementById("delay-sign-btn") as HTMLButtonElement)?.textContent === "−" ? -1 : 1; + setDelayOffset(Math.max(-60, Math.min(10, Math.round(v * sign * 2) / 2))); + (e.target as HTMLInputElement).value = ""; + } + }} + className="w-16 px-2 py-1.5 bg-transparent text-sm text-white text-center focus:outline-none" + /> +
+ +
+ +

+ Pauses the live data feed until it aligns with your broadcast. Set this to match the delay of your streaming service. +

+ + {/* Actions */} +
+ + +
-

- Pauses the live data feed until it aligns with your broadcast. Set this to match the delay of your streaming service so the leaderboard updates at the same time as the TV coverage. -

-
- )} + )}
{/* Live indicator + PiP */} From e8a49157bc7b26de5845d01879ada120d7e3d84e Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sun, 29 Mar 2026 09:51:49 +1100 Subject: [PATCH 05/11] Remove FAKE_LIVE_SESSION dev override from live status endpoint Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/routers/live_status.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/backend/routers/live_status.py b/backend/routers/live_status.py index af489be..2477d41 100644 --- a/backend/routers/live_status.py +++ b/backend/routers/live_status.py @@ -5,7 +5,6 @@ """ import logging -import os from datetime import datetime, timezone, timedelta from fastapi import APIRouter @@ -47,24 +46,6 @@ async def live_status(): Returns the live session details if found, or null. """ - # Dev override: FAKE_LIVE_SESSION=year,round,type,name,event,country - fake = os.environ.get("FAKE_LIVE_SESSION") - if fake: - parts = fake.split(",") - if len(parts) >= 6: - return { - "live": { - "year": int(parts[0]), - "round_number": int(parts[1]), - "event_name": parts[4], - "country": parts[5], - "session_name": parts[3], - "session_type": parts[2], - "session_start": datetime.now(timezone.utc).isoformat(), - "pre_session": False, - } - } - now = datetime.now(timezone.utc) year = now.year From dc275dd8070494da3bd8a8fc29715bd47757c44c Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sun, 29 Mar 2026 11:16:14 +1100 Subject: [PATCH 06/11] Last lap time for all sessions, practice timer fix, and processing feedback - Last lap time column extended to practice and qualifying sessions with timestamp-based lap tracking (requires recompute for time field) - Purple/green colour coding: races use backend flag, practice/qualifying compute from all drivers' laps up to current replay time - Practice session timer counts down from 60 minutes - Loading screen shows real-time backend processing status messages - PiP window inherits CSS variables and high-contrast class - Broadcast delay: "Preparing replay..." status after processing complete Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 11 ++-- backend/routers/replay.py | 1 + backend/services/f1_data.py | 27 +++++++++ backend/services/process.py | 2 +- .../src/app/replay/[year]/[round]/page.tsx | 29 ++++++---- frontend/src/components/Leaderboard.tsx | 58 ++++++++++++++----- frontend/src/components/PiPWindow.tsx | 11 ++++ frontend/src/components/SessionBanner.tsx | 2 +- frontend/src/hooks/useReplaySocket.ts | 5 +- 9 files changed, 115 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d03ca..7f81001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,15 @@ All notable changes to F1 Replay Timing will be documented in this file. ## 1.3.2 ### Improvements -- **Last lap time colour coding** — purple for the overall fastest lap of the race so far, green for a personal best. Matches F1 timing conventions -- **Broadcast delay modal** — redesigned as a full-screen modal with large delay display, slider with zero tick mark, +/- quick adjust buttons (0.5s, 1s, 5s), and exact entry with +/− sign toggle and Set button -- **High contrast text** — toggle in Settings > Other to switch all muted/grey text to white for better readability, especially on lower-brightness screens. Colour-coded elements (intervals, penalties, tyre indicators) are unaffected +- **Last lap time for all sessions** — now available in practice and qualifying. Requires recompute +- **Last lap colour coding** — purple for fastest lap, green for personal best +- **Practice session time** — countdown based on 60-minute session duration +- **Processing feedback** — real-time status messages during on-demand processing +- **Broadcast delay modal** — redesigned with quick adjust buttons and exact entry +- **High contrast text** — toggle in Settings > Other for white text on muted elements ### Fixes -- **Minor UI layout fixes** — improved spacing and alignment across mobile, tablet, and desktop screen sizes +- **Minor UI layout fixes** — spacing and alignment across mobile, tablet, and desktop --- diff --git a/backend/routers/replay.py b/backend/routers/replay.py index 6b02e3c..40307f3 100644 --- a/backend/routers/replay.py +++ b/backend/routers/replay.py @@ -251,6 +251,7 @@ async def send_status(msg: str): # Clear cache entry in case we just processed new data _replay_cache.pop(f"{year}_{round_num}_{type}", None) + await send_status("Preparing replay...") frames = await _get_frames(year, round_num, type) cache_key = f"{year}_{round_num}_{type}" _client_connect(cache_key) diff --git a/backend/services/f1_data.py b/backend/services/f1_data.py index 817cb74..6d6ef01 100644 --- a/backend/services/f1_data.py +++ b/backend/services/f1_data.py @@ -353,6 +353,27 @@ def _get_lap_data_sync(year: int, round_num: int, session_type: str = "R") -> li session = _load_session(year, round_num, session_type) laps = session.laps + # Compute the replay start offset — must match how replay frames are generated. + # Replay uses min(Date) from driver telemetry (get_telemetry()), NOT pos_data. + # FastF1 lap Time is relative to t0_date, so: replay_ts = Time - (min_tel_date - t0_date) + replay_offset_secs = 0.0 + try: + all_dates = [] + drivers_list = laps["Driver"].unique().tolist() + for drv in drivers_list: + drv_laps = laps.pick_drivers(drv) + try: + tel = drv_laps.get_telemetry() + if tel is not None and "Date" in tel.columns and len(tel) > 0: + all_dates.extend(tel["Date"].dropna().tolist()) + except Exception: + continue + if all_dates and hasattr(session, "t0_date") and session.t0_date is not None: + min_date = min(all_dates) + replay_offset_secs = (min_date - session.t0_date).total_seconds() + except Exception: + replay_offset_secs = 0.0 + result = [] for _, lap in laps.iterrows(): def fmt_time(td): @@ -365,11 +386,17 @@ def fmt_time(td): return f"{mins}:{secs:06.3f}" return f"{secs:.3f}" + # Lap completion time relative to replay start (not session start) + lap_end_time = None + if pd.notna(lap.get("Time")): + lap_end_time = round(lap["Time"].total_seconds() - replay_offset_secs, 3) + result.append({ "driver": str(lap.get("Driver", "")), "lap_number": int(lap.get("LapNumber", 0)), "position": int(lap["Position"]) if pd.notna(lap.get("Position")) else None, "lap_time": fmt_time(lap.get("LapTime")), + "time": lap_end_time, "sector1": fmt_time(lap.get("Sector1Time")), "sector2": fmt_time(lap.get("Sector2Time")), "sector3": fmt_time(lap.get("Sector3Time")), diff --git a/backend/services/process.py b/backend/services/process.py index 10438bc..e6e918e 100644 --- a/backend/services/process.py +++ b/backend/services/process.py @@ -123,7 +123,7 @@ def status(msg: str): except Exception as e: logger.warning(f"[{prefix}] Telemetry upload issue: {e}") - status("Done") + status("Processing complete") logger.info(f"[{prefix}] Done") return True diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index 75d1b90..a044134 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -146,16 +146,15 @@ export default function ReplayPage() { ); // Fetch lap data for last lap time column (race/sprint only) + // Fetch lap data for last lap time column (all session types) const { data: lapsResponse } = useApi<{ laps: LapEntry[] }>( - sessionType === "R" || sessionType === "S" - ? `/api/sessions/${year}/${round}/laps?type=${sessionType}` - : null, + `/api/sessions/${year}/${round}/laps?type=${sessionType}`, ); // Build lookup: driver -> lap_number -> lap_time const lapData = useMemo(() => { if (!lapsResponse?.laps) return undefined; - const map = new Map>(); + const map = new Map>(); for (const lap of lapsResponse.laps) { if (!lap.lap_time) continue; let driverMap = map.get(lap.driver); @@ -163,7 +162,7 @@ export default function ReplayPage() { driverMap = new Map(); map.set(lap.driver, driverMap); } - driverMap.set(lap.lap_number, lap.lap_time); + driverMap.set(lap.lap_number, { time: lap.lap_time, completedAt: lap.time ?? null }); } return map; }, [lapsResponse]); @@ -207,10 +206,12 @@ export default function ReplayPage() { return (
-
-

Loading session data...

+
+

+ {replay.statusMessage || "Loading session data..."} +

- First load may take up to 60 seconds while data is fetched + {replay.statusMessage ? "This may take a few minutes for first-time sessions" : "First load may take up to 60 seconds while data is fetched"}

@@ -247,6 +248,12 @@ export default function ReplayPage() { const weather = replay.frame?.weather; const isRace = sessionType === "R" || sessionType === "S"; const isQualifying = sessionType === "Q" || sessionType === "SQ"; + const isPractice = sessionType === "FP1" || sessionType === "FP2" || sessionType === "FP3"; + + // For practice sessions, cap the total time at the official session duration (60 min) + // so the "remaining" timer is accurate rather than including post-session telemetry + const PRACTICE_DURATION = 3600; // 60 minutes + const effectiveTotalTime = isPractice ? Math.min(replay.totalTime, PRACTICE_DURATION) : replay.totalTime; // Compute sector overlay for track map const SECTOR_HEX: Record = { purple: "#A855F7", green: "#22C55E", yellow: "#EAB308" }; @@ -276,7 +283,7 @@ export default function ReplayPage() { if (!isRace) w += 18; // pit indicator (P box + margin) if (isRace && settings.showGridChange) w += 24; if (!isRace && settings.showBestLapTime) w += 60; // best lap time column - if (isRace && settings.showLastLapTime) w += 60; // last lap time column + if (settings.showLastLapTime) w += 60; // last lap time column if (settings.showGapToLeader) w += 56 + (!isRace ? 8 : 0); // extra margin between lap time and gap in practice/qualifying if (isQualifying && settings.showSectors) w += 36; // sector indicators (28 + 8 margin) if (isRace && settings.showPitStops) w += 24; @@ -859,7 +866,7 @@ export default function ReplayPage() { playing={replay.playing} speed={replay.speed} currentTime={replay.frame?.timestamp || 0} - totalTime={replay.totalTime} + totalTime={effectiveTotalTime} currentLap={replay.frame?.lap || 0} totalLaps={replay.totalLaps} finished={replay.finished} @@ -1045,7 +1052,7 @@ export default function ReplayPage() { playing={replay.playing} speed={replay.speed} currentTime={replay.frame?.timestamp || 0} - totalTime={replay.totalTime} + totalTime={effectiveTotalTime} currentLap={replay.frame?.lap || 0} totalLaps={replay.totalLaps} finished={replay.finished} diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index 97f451f..ce68487 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -9,6 +9,7 @@ export interface LapEntry { driver: string; lap_number: number; lap_time: string | null; + time: number | null; // session-elapsed seconds when lap was completed compound: string | null; pit_in: boolean; pit_out: boolean; @@ -24,7 +25,7 @@ interface Props { isQualifying?: boolean; compact?: boolean; onScaleChange?: (scale: number) => void; - lapData?: Map>; + lapData?: Map>; currentLap?: number; mobileTeamAbbrHidden?: boolean; } @@ -287,18 +288,32 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick ) )} - {/* Last lap time (race only) */} - {isRace && settings.showLastLapTime && (() => { + {/* Last lap time */} + {settings.showLastLapTime && (() => { const driverLaps = lapData?.get(drv.abbr); - if (!driverLaps || !currentLap || currentLap < 2) return ( + if (!driverLaps) return ( ); + + // Find the driver's most recent completed lap up to the current replay time let lastLapTime: string | null = null; let lastLapNum = 0; - for (let l = currentLap; l >= 1; l--) { - const t = driverLaps.get(l); - if (t) { lastLapTime = t; lastLapNum = l; break; } + if (isRace) { + // Races: use global currentLap + for (let l = (currentLap || 0); l >= 1; l--) { + const entry = driverLaps.get(l); + if (entry) { lastLapTime = entry.time; lastLapNum = l; break; } + } + } else { + // Practice/qualifying: use timestamp to find laps completed before current time + for (const [lapNum, entry] of driverLaps) { + if (entry.completedAt !== null && entry.completedAt <= currentTime && lapNum > lastLapNum) { + lastLapTime = entry.time; + lastLapNum = lapNum; + } + } } + if (!lastLapTime || lastLapNum < 2 || drv.retired) return ( {drv.retired ? "" : (lastLapTime || "")} @@ -312,16 +327,33 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick }; const lastSecs = toSecs(lastLapTime); - // Check personal best (this driver's laps 2+ up to current) + // Check personal best (this driver's completed laps up to now) let personalBest = Infinity; - for (let l = 2; l <= currentLap; l++) { - const t = driverLaps.get(l); - if (t) { const s = toSecs(t); if (s < personalBest) personalBest = s; } + for (const [lapNum, entry] of driverLaps) { + if (lapNum < 2) continue; + if (!isRace && entry.completedAt !== null && entry.completedAt > currentTime) continue; + if (isRace && lapNum > (currentLap || 0)) continue; + const s = toSecs(entry.time); + if (s < personalBest) personalBest = s; } const isPersonalBest = lastSecs <= personalBest + 0.0005; - // Purple: backend says this driver holds fastest lap AND their last lap equals their personal best - const isFastest = drv.has_fastest_lap && isPersonalBest; + // Purple: for races, use backend flag. For practice/qualifying, compute from all drivers' laps. + let isFastest = false; + if (isRace) { + isFastest = drv.has_fastest_lap && isPersonalBest; + } else if (isPersonalBest && lapData) { + let overallFastest = Infinity; + for (const [, laps] of lapData) { + for (const [lapNum, entry] of laps) { + if (lapNum < 2) continue; + if (entry.completedAt !== null && entry.completedAt > currentTime) continue; + const s = toSecs(entry.time); + if (s < overallFastest) overallFastest = s; + } + } + isFastest = lastSecs <= overallFastest + 0.0005; + } const color = isFastest ? "text-purple-400" : isPersonalBest ? "text-green-400" : "text-f1-muted"; diff --git a/frontend/src/components/PiPWindow.tsx b/frontend/src/components/PiPWindow.tsx index 693017f..c3cbe69 100644 --- a/frontend/src/components/PiPWindow.tsx +++ b/frontend/src/components/PiPWindow.tsx @@ -57,6 +57,17 @@ export default function PiPWindow({ } } + // Copy CSS custom properties from main document root + const mainStyles = getComputedStyle(document.documentElement); + const cssVars = ["--f1-muted"]; + for (const v of cssVars) { + pipWin.document.documentElement.style.setProperty(v, mainStyles.getPropertyValue(v)); + } + // Copy high-contrast class if active + if (document.documentElement.classList.contains("high-contrast")) { + pipWin.document.documentElement.classList.add("high-contrast"); + } + const mount = pipWin.document.createElement("div"); mount.id = "pip-root"; mount.style.width = "100%"; diff --git a/frontend/src/components/SessionBanner.tsx b/frontend/src/components/SessionBanner.tsx index b7e52d4..9b83064 100644 --- a/frontend/src/components/SessionBanner.tsx +++ b/frontend/src/components/SessionBanner.tsx @@ -31,7 +31,7 @@ const LEADERBOARD_SETTINGS: { key: keyof ReplaySettings; label: string; raceOnly { key: "showTeamAbbr", label: "Team" }, { key: "showGridChange", label: "Grid position change", raceOnly: true }, { key: "showBestLapTime", label: "Best time", nonRaceOnly: true }, - { key: "showLastLapTime", label: "Last lap time", raceOnly: true }, + { key: "showLastLapTime", label: "Last lap time" }, { key: "showGapToLeader", label: "Gap" }, { key: "highlightClose", label: "Highlight under 1s", raceOnly: true }, { key: "showPitStops", label: "Pit stops", raceOnly: true }, diff --git a/frontend/src/hooks/useReplaySocket.ts b/frontend/src/hooks/useReplaySocket.ts index 7077ae3..917c345 100644 --- a/frontend/src/hooks/useReplaySocket.ts +++ b/frontend/src/hooks/useReplaySocket.ts @@ -93,6 +93,7 @@ interface ReplayState { qualiPhases: QualiPhaseInfo[]; finished: boolean; error: string | null; + statusMessage: string | null; } export function useReplaySocket(year: number, round: number, sessionType: string = "R") { @@ -109,6 +110,7 @@ export function useReplaySocket(year: number, round: number, sessionType: string qualiPhases: [], finished: false, error: null, + statusMessage: null, }); useEffect(() => { @@ -125,13 +127,14 @@ export function useReplaySocket(year: number, round: number, sessionType: string switch (msg.type) { case "status": - setState((s) => ({ ...s, loading: true })); + setState((s) => ({ ...s, loading: true, statusMessage: msg.message || null })); break; case "ready": setState((s) => ({ ...s, ready: true, loading: false, + statusMessage: null, totalTime: msg.total_time, totalLaps: msg.total_laps, qualiPhases: msg.quali_phases || [], From dec8d478b5026ba44f2c0ce9dba11a7cde929a94 Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sun, 29 Mar 2026 13:55:04 +1100 Subject: [PATCH 07/11] Fix practice session timestamp alignment and UI improvements - Fixed practice precompute: session_time_offset now uses direct t0_date calculation instead of unreliable SessionTime lookup, fixing leaderboard showing future data at session start - Practice countdown aligned to 60-minute session duration - Mobile/PiP qualifying shows remaining time instead of elapsed - PiP leaderboard scroll no longer cut off by playback controls Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 5 +++- backend/services/f1_data.py | 23 +++++++++++-------- .../src/app/replay/[year]/[round]/page.tsx | 14 +++++------ frontend/src/components/PlaybackControls.tsx | 2 +- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f81001..325d97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,15 @@ All notable changes to F1 Replay Timing will be documented in this file. ### Improvements - **Last lap time for all sessions** — now available in practice and qualifying. Requires recompute - **Last lap colour coding** — purple for fastest lap, green for personal best -- **Practice session time** — countdown based on 60-minute session duration - **Processing feedback** — real-time status messages during on-demand processing - **Broadcast delay modal** — redesigned with quick adjust buttons and exact entry - **High contrast text** — toggle in Settings > Other for white text on muted elements ### Fixes +- **Practice session precompute** — fixed timestamp alignment for practice sessions causing leaderboard to show future data. Requires recompute +- **Practice session time** — countdown aligned to 60-minute session duration, not overall replay length +- **Mobile qualifying time** — now shows remaining time counting down, matching desktop +- **PiP leaderboard scroll** — leaderboard no longer cut off by playback controls - **Minor UI layout fixes** — spacing and alignment across mobile, tablet, and desktop --- diff --git a/backend/services/f1_data.py b/backend/services/f1_data.py index 6d6ef01..e17731a 100644 --- a/backend/services/f1_data.py +++ b/backend/services/f1_data.py @@ -927,16 +927,21 @@ def _format_lap_time(seconds: float) -> str: # Compute session time offset: t_sec (from min_date) + session_time_offset = session timedelta # This is needed because gap data uses session timedeltas, not min_date offsets + is_practice = session_type in ("FP1", "FP2", "FP3") session_time_offset = 0.0 - for tel in driver_pos_data.values(): - if "SessionTime" in tel.columns and "Date" in tel.columns and len(tel) > 0: - # Find the entry closest to min_date - diffs = (tel["Date"] - min_date).abs() - closest_idx = diffs.idxmin() - st = tel.loc[closest_idx, "SessionTime"] - if pd.notna(st): - session_time_offset = st.total_seconds() - break + if is_practice and hasattr(session, "t0_date") and session.t0_date is not None: + # For practice: use direct calculation from t0_date to min_date + session_time_offset = (min_date - session.t0_date).total_seconds() + else: + for tel in driver_pos_data.values(): + if "SessionTime" in tel.columns and "Date" in tel.columns and len(tel) > 0: + # Find the entry closest to min_date + diffs = (tel["Date"] - min_date).abs() + closest_idx = diffs.idxmin() + st = tel.loc[closest_idx, "SessionTime"] + if pd.notna(st): + session_time_offset = st.total_seconds() + break # Pre-compute track status (yellow/SC/VSC/red) lookup # track_status Time is a session timedelta, same as gap data diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index a044134..dcfee97 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -211,7 +211,7 @@ export default function ReplayPage() { {replay.statusMessage || "Loading session data..."}

- {replay.statusMessage ? "This may take a few minutes for first-time sessions" : "First load may take up to 60 seconds while data is fetched"} + {replay.statusMessage ? "This may take a few minutes the first time a session loads" : "First load may take up to 60 seconds while data is fetched"}

@@ -898,9 +898,9 @@ export default function ReplayPage() { {/* Document PiP window — visible across tabs */} {pipActive && !isMobile && !isIOS && ( setPipActive(false)} width={400} height={780}> -
+
{/* PiP Track Map */} -
+
{/* PiP Race Control */} -
+
{/* PiP Telemetry */} -
+
{/* PiP Leaderboard */} -
+
{pipLeaderboardOpen && ( -
+
{playPauseBtn} - {formatTime(currentTime)} + {!isRace && qualiPhase ? formatTime(qualiPhase.remaining) : !isRace ? formatTime(Math.max(0, totalTime - currentTime)) : formatTime(currentTime)} {isRace && currentLap > 0 && L{currentLap}/{totalLaps}} {!isRace && qualiPhase && {qualiPhase.phase}} From b29bf1a7c06ebfa1f324555feaebbf71eae598be Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sun, 29 Mar 2026 15:10:54 +1100 Subject: [PATCH 08/11] Add sector indicators and track overlay to practice sessions - Backend: extended sector event computation to FP1/FP2/FP3 - Frontend: sector column and track overlay now show for all non-race sessions - Settings: live sectors toggle available in practice (was qualifying only) - Requires recompute for practice sessions Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + backend/services/f1_data.py | 8 ++++---- frontend/src/app/replay/[year]/[round]/page.tsx | 9 +++++---- frontend/src/components/Leaderboard.tsx | 4 ++-- frontend/src/components/SessionBanner.tsx | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325d97d..bf92a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to F1 Replay Timing will be documented in this file. ## 1.3.2 ### Improvements +- **Practice sector indicators** — live sector colours and track map sector overlay now available in practice sessions. Requires recompute - **Last lap time for all sessions** — now available in practice and qualifying. Requires recompute - **Last lap colour coding** — purple for fastest lap, green for personal best - **Processing feedback** — real-time status messages during on-demand processing diff --git a/backend/services/f1_data.py b/backend/services/f1_data.py index e17731a..23c680c 100644 --- a/backend/services/f1_data.py +++ b/backend/services/f1_data.py @@ -879,12 +879,12 @@ def _get_driver_flag(abbr: str, frame_time: float) -> str | None: driver_best_lap_events[drv] = events driver_lap_completions[drv] = completions - # For qualifying sessions: build sector completion events per driver + # For qualifying and practice sessions: build sector completion events per driver # Each entry: (session_time, sector_num, sector_time_seconds, lap_number, is_out_lap) # Also pre-compute which laps are out laps (lap 1 or first lap after pit exit) driver_sector_events: dict[str, list[tuple[float, int, float, int, bool]]] = {} driver_out_laps: dict[str, set[int]] = {} - if session_type in ("Q", "SQ"): + if session_type in ("Q", "SQ", "FP1", "FP2", "FP3"): for drv in drivers_list: drv_laps_df = laps.pick_drivers(drv).sort_values("LapNumber") sector_events = [] @@ -1474,8 +1474,8 @@ def _safe_float(v) -> float: d["gap"] = "No time" d["no_timing"] = False - # Add live sector indicators for qualifying - if session_type in ("Q", "SQ"): + # Add live sector indicators for qualifying and practice + if session_type in ("Q", "SQ", "FP1", "FP2", "FP3"): # Track overall best and personal best sector times up to now overall_best_sectors: dict[int, float] = {} # sector_num -> best time personal_best_sectors: dict[str, dict[int, float]] = {} # driver -> sector_num -> best time diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index dcfee97..7db2c1a 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -249,6 +249,7 @@ export default function ReplayPage() { const isRace = sessionType === "R" || sessionType === "S"; const isQualifying = sessionType === "Q" || sessionType === "SQ"; const isPractice = sessionType === "FP1" || sessionType === "FP2" || sessionType === "FP3"; + const hasSectors = isQualifying || isPractice; // For practice sessions, cap the total time at the official session duration (60 min) // so the "remaining" timer is accurate rather than including post-session telemetry @@ -259,7 +260,7 @@ export default function ReplayPage() { const SECTOR_HEX: Record = { purple: "#A855F7", green: "#22C55E", yellow: "#EAB308" }; const DEFAULT_SECTOR = "#3A3A4A"; const sectorOverlay: SectorOverlay | null = (() => { - if (!isQualifying || !showSectorOverlay || !trackData?.sector_boundaries) return null; + if (!hasSectors || !showSectorOverlay || !trackData?.sector_boundaries) return null; const target = sectorFocusDriver && selectedDrivers.includes(sectorFocusDriver) ? sectorFocusDriver : null; @@ -285,7 +286,7 @@ export default function ReplayPage() { if (!isRace && settings.showBestLapTime) w += 60; // best lap time column if (settings.showLastLapTime) w += 60; // last lap time column if (settings.showGapToLeader) w += 56 + (!isRace ? 8 : 0); // extra margin between lap time and gap in practice/qualifying - if (isQualifying && settings.showSectors) w += 36; // sector indicators (28 + 8 margin) + if (hasSectors && settings.showSectors) w += 36; // sector indicators (28 + 8 margin) if (isRace && settings.showPitStops) w += 24; if (isRace && settings.showTyreHistory) w += 36; if (settings.showTyreType) w += 24; @@ -535,7 +536,7 @@ export default function ReplayPage() { {/* Telemetry now in bottom drawer */} {/* Sector overlay toggle - desktop qualifying only */} - {!isMobile && isQualifying && trackData?.sector_boundaries && ( + {!isMobile && hasSectors && trackData?.sector_boundaries && (
{showSectorOverlay && selectedDrivers.length === 0 && ( Select a driver to view sectors @@ -574,7 +575,7 @@ export default function ReplayPage() { )} {/* Sector overlay controls - mobile qualifying only */} - {isMobile && isQualifying && trackData?.sector_boundaries && ( + {isMobile && hasSectors && trackData?.sector_boundaries && (
{showSectorOverlay && selectedDrivers.length > 0 && (
diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index ce68487..6e06d50 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -364,8 +364,8 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick ); })()} - {/* Live sector indicators - fixed width (qualifying only) */} - {isQualifying && settings.showSectors && ( + {/* Live sector indicators - fixed width (qualifying and practice) */} + {!isRace && settings.showSectors && ( {[1, 2, 3].map((sn) => { const sec = drv.sectors?.find((s) => s.num === sn); diff --git a/frontend/src/components/SessionBanner.tsx b/frontend/src/components/SessionBanner.tsx index 9b83064..9c9ff1b 100644 --- a/frontend/src/components/SessionBanner.tsx +++ b/frontend/src/components/SessionBanner.tsx @@ -38,7 +38,7 @@ const LEADERBOARD_SETTINGS: { key: keyof ReplaySettings; label: string; raceOnly { key: "showTyreType", label: "Tyre type" }, { key: "showTyreAge", label: "Tyre age" }, { key: "showTyreHistory", label: "Tyre history", raceOnly: true }, - { key: "showSectors", label: "Live sectors", qualiOnly: true }, + { key: "showSectors", label: "Live sectors", nonRaceOnly: true }, { key: "showPitPrediction", label: "Pit prediction", raceOnly: true }, { key: "showPitConfidence", label: "Confidence", raceOnly: true, parent: "showPitPrediction" }, { key: "showPitFreeAir", label: "Pit gaps", raceOnly: true, parent: "showPitPrediction" }, From 540849672e3093cf47f13471b1fdcf4b79bb1ca5 Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sun, 29 Mar 2026 15:32:05 +1100 Subject: [PATCH 09/11] Add "Open all data panels" setting and practice sector support - New toggle in Settings > Other opens telemetry, race control, and lap analysis (race only) in one click with bottom panel layout - Closing any panel turns off the toggle - Telemetry shows placeholder text when no drivers selected - Practice sectors and all prior 1.3.2 improvements Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../src/app/replay/[year]/[round]/page.tsx | 56 ++++++++++++++----- frontend/src/components/SessionBanner.tsx | 1 + frontend/src/hooks/useSettings.ts | 2 + 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf92a2d..203510a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to F1 Replay Timing will be documented in this file. - **Last lap colour coding** — purple for fastest lap, green for personal best - **Processing feedback** — real-time status messages during on-demand processing - **Broadcast delay modal** — redesigned with quick adjust buttons and exact entry +- **Open all data panels** — toggle in Settings > Other to open telemetry, race control, and lap analysis (race only) in a single click. Closes automatically when any panel is hidden - **High contrast text** — toggle in Settings > Other for white text on muted elements ### Fixes diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index 7db2c1a..50dbec8 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -63,7 +63,9 @@ export default function ReplayPage() { const [lapAnalysisOpen, setLapAnalysisOpen] = useState(false); const [mobileLapAnalysisOpen, setMobileLapAnalysisOpen] = useState(false); // Force telemetry to bottom when lap analysis panel is open to avoid squashing the track map - const effectiveTelemetryPosition = lapAnalysisOpen && telemetryPosition === "left" ? "bottom" : telemetryPosition; + // effectiveTelemetryPosition is computed later after settings is available + const [forceBottomTelemetry, setForceBottomTelemetry] = useState(false); + const effectiveTelemetryPosition = (lapAnalysisOpen || forceBottomTelemetry) && telemetryPosition === "left" ? "bottom" : telemetryPosition; const [leaderboardScale, setLeaderboardScale] = useState(1); const [pipTrackOpen, setPipTrackOpen] = useState(true); const [pipTelemetryOpen, setPipTelemetryOpen] = useState(false); @@ -198,6 +200,21 @@ export default function ReplayPage() { } }, [selectedDrivers.length, showTelemetry, effectiveTelemetryPosition]); + const isRace = sessionType === "R" || sessionType === "S"; + + // "Open all data panels" — must be before early returns to maintain hook order + useEffect(() => { + if (settings.showAllPanels) { + setShowTelemetry(true); + setRcPinned(true); + setRcPanelOpen(false); + setForceBottomTelemetry(true); + if (isRace) setLapAnalysisOpen(true); + } else { + setForceBottomTelemetry(false); + } + }, [settings.showAllPanels]); // eslint-disable-line react-hooks/exhaustive-deps + const isLoading = sessionLoading || trackLoading; const dataError = sessionError || trackError; @@ -246,11 +263,16 @@ export default function ReplayPage() { ? Math.max(0, redFlagEnd - replay.frame.timestamp) : null; const weather = replay.frame?.weather; - const isRace = sessionType === "R" || sessionType === "S"; const isQualifying = sessionType === "Q" || sessionType === "SQ"; const isPractice = sessionType === "FP1" || sessionType === "FP2" || sessionType === "FP3"; const hasSectors = isQualifying || isPractice; + // Turn off showAllPanels when user manually closes any panel + function closePanel(closeFn: () => void) { + closeFn(); + if (settings.showAllPanels) updateSetting("showAllPanels", false); + } + // For practice sessions, cap the total time at the official session duration (60 min) // so the "remaining" timer is accurate rather than including post-session telemetry const PRACTICE_DURATION = 3600; // 60 minutes @@ -354,7 +376,7 @@ export default function ReplayPage() {
{/* Track section */} -
2 ? `flex ${effectiveTelemetryPosition === "left" ? "flex-row" : "flex-col"} min-h-0` : "relative"}`}> +
2 || settings.showAllPanels) ? `flex ${effectiveTelemetryPosition === "left" ? "flex-row" : "flex-col"} min-h-0` : "relative"}`}> {/* Mobile section header */} {isMobile && ( )}
- {selectedDrivers.map((abbr) => { - const drv = drivers.find((d) => d.abbr === abbr) || null; - return ; - })} + {selectedDrivers.length > 0 ? ( + selectedDrivers.map((abbr) => { + const drv = drivers.find((d) => d.abbr === abbr) || null; + return ; + }) + ) : ( +

Select drivers on the leaderboard to view telemetry

+ )}
@@ -732,7 +758,7 @@ export default function ReplayPage() {
Race Control
{/* Track section */} -
2 || settings.showAllPanels) ? `flex ${effectiveTelemetryPosition === "left" ? "flex-row" : "flex-col"} min-h-0` : "relative"}`}> +
2 || settings.showAllPanels || rcPinned) ? `flex ${effectiveTelemetryPosition === "left" ? "flex-row" : "flex-col"} min-h-0` : "relative"}`}> {/* Mobile section header */} {isMobile && (