diff --git a/CHANGELOG.md b/CHANGELOG.md
index e30e5bd..e110db0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,27 @@
# 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
+- **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
+- **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
+- **Persistent panel layout** — panel states (telemetry, race control, lap analysis, sectors) are remembered per session type (race, qualifying, practice) across page loads
+- **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
+
+---
## 1.3.1
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..23c680c 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")),
@@ -852,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 = []
@@ -900,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
@@ -1442,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/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/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/live/[year]/[round]/page.tsx b/frontend/src/app/live/[year]/[round]/page.tsx
index 262f0cc..441d11e 100644
--- a/frontend/src/app/live/[year]/[round]/page.tsx
+++ b/frontend/src/app/live/[year]/[round]/page.tsx
@@ -599,57 +599,157 @@ export default function LivePage() {
>
Delay: {delayOffset > 0 ? "+" : ""}{delayOffset}s
- {showDelaySlider && (
-
-
-
Broadcast Delay
-
{ setDelayOffset(0); }}
- className="text-[10px] text-f1-muted hover:text-white"
- >
- Reset
+ {showDelaySlider && (<>
+ {/* Modal backdrop */}
+ setShowDelaySlider(false)} />
+
+ {/* Delay modal */}
+
+
+
Broadcast Delay
+
setShowDelaySlider(false)} className="text-f1-muted hover:text-white">
+
+
+
-
setDelayOffset(Number(e.target.value))}
- className="w-full h-1 bg-f1-border rounded-lg appearance-none cursor-pointer accent-blue-500"
- />
-
- -60s
- +10s
-
-
-
Exact delay:
-
{
- 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 }) => (
+ setDelayOffset(Math.max(-60, Math.min(10, Math.round((delayOffset + delta) * 2) / 2)))}
+ className="px-2 py-1.5 bg-f1-dark border border-f1-border rounded text-[11px] font-bold text-f1-muted hover:text-white hover:border-blue-500/50 transition-colors"
+ >
+ {label}
+
+ ))}
+
+ {[
+ { label: "+0.5s", delta: 0.5 },
+ { label: "+1s", delta: 1 },
+ { label: "+5s", delta: 5 },
+ ].map(({ label, delta }) => (
+ setDelayOffset(Math.max(-60, Math.min(10, Math.round((delayOffset + delta) * 2) / 2)))}
+ className="px-2 py-1.5 bg-f1-dark border border-f1-border rounded text-[11px] font-bold text-f1-muted hover:text-white hover:border-blue-500/50 transition-colors"
+ >
+ {label}
+
+ ))}
+
+
+ {/* Manual input */}
+
+
Exact:
+
+ {
+ const btn = document.getElementById("delay-sign-btn") as HTMLButtonElement;
+ const current = btn.textContent === "−" ? "−" : "+";
+ btn.textContent = current === "−" ? "+" : "−";
+ }}
+ className="px-2 py-1.5 text-sm font-bold text-f1-muted hover:text-white border-r border-f1-border transition-colors w-8 text-center"
+ >
+ −
+
+ { 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"
+ />
+
+
{
+ const input = document.getElementById("delay-exact-input") as HTMLInputElement;
+ if (!input || input.value === "") return;
+ const v = Math.abs(Number(input.value));
+ 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)));
+ input.value = "";
+ }}
+ className="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-bold rounded transition-colors"
+ >
+ Set
+
+
+
+
+ Pauses the live data feed until it aligns with your broadcast. Set this to match the delay of your streaming service.
+
+
+ {/* Actions */}
+
+ { setDelayOffset(0); }}
+ className="flex-1 px-3 py-2 bg-f1-dark border border-f1-border text-f1-muted hover:text-white text-xs font-bold rounded transition-colors"
+ >
+ Reset to 0
+
+ setShowDelaySlider(false)}
+ className="flex-1 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white text-xs font-bold rounded transition-colors"
+ >
+ Confirm
+
+
-
- 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.
-
-
setShowDelaySlider(false)}
- className="w-full mt-2 px-2 py-1 bg-blue-500 hover:bg-blue-600 text-white text-[10px] font-bold rounded transition-colors"
- >
- Confirm
-
- )}
+ >)}
{/* Live indicator + PiP */}
diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx
index faa58c5..25434ca 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);
@@ -73,6 +75,46 @@ export default function ReplayPage() {
const [sectorFocusDriver, setSectorFocusDriver] = useState
(null);
const [rcPanelOpen, setRcPanelOpen] = useState(false);
const [rcPinned, setRcPinned] = useState(false);
+
+ // Persist panel layout per session type
+ const layoutCategory = sessionType === "R" || sessionType === "S" ? "race"
+ : sessionType === "Q" || sessionType === "SQ" ? "qualifying"
+ : "practice";
+ const layoutKey = `f1replay_layout_${layoutCategory}`;
+ const layoutLoadedRef = useRef(false);
+
+ // Load saved layout on mount
+ useEffect(() => {
+ try {
+ const saved = localStorage.getItem(layoutKey);
+ if (saved) {
+ const layout = JSON.parse(saved);
+ if (layout.showTelemetry != null) setShowTelemetry(layout.showTelemetry);
+ if (layout.telemetryPosition != null) setTelemetryPosition(layout.telemetryPosition);
+ if (layout.rcPinned != null) setRcPinned(layout.rcPinned);
+ if (layout.rcPanelOpen != null) setRcPanelOpen(layout.rcPanelOpen);
+ if (layout.lapAnalysisOpen != null) setLapAnalysisOpen(layout.lapAnalysisOpen);
+ if (layout.showSectorOverlay != null) setShowSectorOverlay(layout.showSectorOverlay);
+ }
+ } catch {}
+ // Allow saving after load completes
+ setTimeout(() => { layoutLoadedRef.current = true; }, 100);
+ }, [layoutKey]);
+
+ // Save layout when panel states change (only after initial load)
+ useEffect(() => {
+ if (!layoutLoadedRef.current) return;
+ try {
+ localStorage.setItem(layoutKey, JSON.stringify({
+ showTelemetry,
+ telemetryPosition,
+ rcPinned,
+ rcPanelOpen,
+ lapAnalysisOpen,
+ showSectorOverlay,
+ }));
+ } catch {}
+ }, [showTelemetry, telemetryPosition, rcPinned, rcPanelOpen, lapAnalysisOpen, showSectorOverlay, layoutKey]);
const [rcPanelSize, setRcPanelSize] = useState<"sm" | "md" | "lg">("md");
const [rcPosition, setRcPosition] = useState<{ x: number; y: number } | null>(null);
const rcDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
@@ -146,16 +188,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 +204,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]);
@@ -199,6 +240,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;
@@ -207,10 +263,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 the first time a session loads" : "First load may take up to 60 seconds while data is fetched"}
@@ -245,14 +303,26 @@ 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
+ 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" };
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;
@@ -276,9 +346,9 @@ 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.showGapToLeader) w += 56;
- if (isQualifying && settings.showSectors) w += 36; // sector indicators (28 + 8 margin)
+ 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 (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;
@@ -346,7 +416,7 @@ export default function ReplayPage() {
{/* Track section */}
- 2 ? `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 && (
2 ? "flex-1 min-w-0 min-h-0" : ""}`}>
+ 2 || settings.showAllPanels || rcPinned) ? "flex-1 min-w-0 min-h-0" : ""}`}>
{/* Flag badge */}
{trackStatus !== "green" && (
@@ -528,7 +598,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
@@ -567,7 +637,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 && (
@@ -610,7 +680,7 @@ export default function ReplayPage() {
{/* Fullscreen toggle moved to PlaybackControls */}
{/* Telemetry overlay - desktop only, bottom-left (1-2 drivers) */}
- {!isMobile && showTelemetry && selectedDrivers.length <= 2 && (
+ {!isMobile && showTelemetry && selectedDrivers.length <= 2 && !settings.showAllPanels && !rcPinned && (
setShowTelemetry(false)}
@@ -630,7 +700,7 @@ export default function ReplayPage() {
)}
{/* Telemetry toggle - desktop only, bottom-left */}
- {!isMobile && !showTelemetry && (
+ {!isMobile && !showTelemetry && !settings.showAllPanels && (
setShowTelemetry(true)}
className="absolute bottom-2 left-3 z-20 px-2 py-1 bg-f1-card/90 border border-f1-border rounded text-[10px] font-bold text-f1-muted hover:text-white transition-colors backdrop-blur-sm"
@@ -659,7 +729,7 @@ export default function ReplayPage() {
)}
{/* Telemetry panel - desktop only (3+ drivers) */}
- {!isMobile && showTelemetry && selectedDrivers.length > 2 && (
+ {!isMobile && showTelemetry && (selectedDrivers.length > 2 || settings.showAllPanels || rcPinned) && (
Telemetry
{lapAnalysisOpen ? (
- Shown at bottom while Lap Analysis is open
+ {forceBottomTelemetry ? "Shown at bottom (all panels open)" : "Shown at bottom while Lap Analysis is open"}
) : (
setTelemetryPosition(telemetryPosition === "left" ? "bottom" : "left")}
@@ -683,17 +753,21 @@ export default function ReplayPage() {
)}
setShowTelemetry(false)}
+ onClick={() => closePanel(() => setShowTelemetry(false))}
className="px-1.5 py-0.5 text-[9px] font-bold text-f1-muted hover:text-white border border-f1-border rounded transition-colors ml-auto"
>
Hide
- {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
+ )}
@@ -724,7 +798,7 @@ export default function ReplayPage() {
Race Control
setRcPinned(false)}
+ onClick={() => closePanel(() => setRcPinned(false))}
className="px-1.5 py-0.5 text-[9px] font-bold text-f1-muted hover:text-white border border-f1-border rounded transition-colors"
>
Hide
@@ -792,7 +866,7 @@ export default function ReplayPage() {
{/* Lap Analysis Panel - desktop only, left of leaderboard */}
{!isMobile && isRace && lapAnalysisOpen && lapsResponse?.laps && (
- setLapAnalysisOpen(false)} />
+ closePanel(() => setLapAnalysisOpen(false))} />
)}
@@ -859,7 +933,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}
@@ -891,9 +965,9 @@ export default function ReplayPage() {
{/* Document PiP window — visible across tabs */}
{pipActive && !isMobile && !isIOS && (
setPipActive(false)} width={400} height={780}>
-
+
{/* PiP Track Map */}
-
+
setPipTrackOpen(!pipTrackOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-f1-card border-b border-f1-border"
@@ -952,7 +1026,7 @@ export default function ReplayPage() {
{/* PiP Race Control */}
-
+
setPipRcOpen(!pipRcOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-f1-card border-b border-f1-border"
@@ -986,7 +1060,7 @@ export default function ReplayPage() {
{/* PiP Telemetry */}
-
+
setPipTelemetryOpen(!pipTelemetryOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-f1-card border-b border-f1-border"
@@ -1011,7 +1085,7 @@ export default function ReplayPage() {
{/* PiP Leaderboard */}
-
+
setPipLeaderboardOpen(!pipLeaderboardOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-f1-card border-b border-f1-border flex-shrink-0"
@@ -1022,7 +1096,7 @@ export default function ReplayPage() {
{pipLeaderboardOpen && (
-
+
void;
- lapData?: Map>;
+ lapData?: Map>;
currentLap?: number;
mobileTeamAbbrHidden?: boolean;
}
@@ -157,17 +158,17 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick
onDriverClick(drv.abbr)}
- className={`w-full flex items-center px-2 py-1 hover:bg-white/5 transition-colors text-left ${
+ className={`w-full flex items-center px-1 sm:px-2 py-1 hover:bg-white/5 transition-colors text-left ${
isHighlighted ? "bg-white/10" : ""
} ${drv.no_timing ? "opacity-40" : ""}`}
>
- {/* Position - 24px */}
+ {/* Position - 20px mobile, 24px desktop */}
{isLeader ? (
-
+
{drv.position}
) : (
-
+
{drv.position ?? "-"}
)}
@@ -281,32 +282,90 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick
)
) : (
-
+
{displayGap}
)
)}
- {/* 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;
- for (let l = currentLap; l >= 1; l--) {
- const t = driverLaps.get(l);
- if (t) { lastLapTime = t; break; }
+ let lastLapNum = 0;
+ 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;
+ }
+ }
}
- return (
-
+
+ if (!lastLapTime || lastLapNum < 2 || drv.retired) return (
+
{drv.retired ? "" : (lastLapTime || "")}
);
+
+ // Parse time string to seconds for comparison
+ const toSecs = (t: string): number => {
+ const p = t.split(":");
+ return p.length === 2 ? parseInt(p[0]) * 60 + parseFloat(p[1]) : parseFloat(p[0]) || Infinity;
+ };
+ const lastSecs = toSecs(lastLapTime);
+
+ // Check personal best (this driver's completed laps up to now)
+ let personalBest = Infinity;
+ 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: 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";
+
+ return (
+
+ {lastLapTime}
+
+ );
})()}
- {/* 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/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/PlaybackControls.tsx b/frontend/src/components/PlaybackControls.tsx
index e90d027..e9f23d6 100644
--- a/frontend/src/components/PlaybackControls.tsx
+++ b/frontend/src/components/PlaybackControls.tsx
@@ -126,7 +126,7 @@ export default function PlaybackControls({
{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} }
diff --git a/frontend/src/components/SessionBanner.tsx b/frontend/src/components/SessionBanner.tsx
index 3602e68..ce039cc 100644
--- a/frontend/src/components/SessionBanner.tsx
+++ b/frontend/src/components/SessionBanner.tsx
@@ -31,14 +31,14 @@ 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 },
{ 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" },
@@ -57,9 +57,11 @@ const TRACK_MAP_SETTINGS: { key: keyof ReplaySettings; label: string }[] = [
{ key: "showCorners", label: "Corner numbers" },
];
-const OTHER_SETTINGS: { key: keyof ReplaySettings; label: string }[] = [
- { key: "showSessionTime", label: "Total session time" },
+const OTHER_SETTINGS: { key: keyof ReplaySettings; label: string; hint?: string }[] = [
+ { key: "showAllPanels", label: "Open all data panels" },
+ { key: "showSessionTime", label: "Show total session time", hint: "May indicate red flags and spoilers" },
{ key: "useImperial", label: "Imperial units (°F, mph)" },
+ { key: "highContrast", label: "High contrast text" },
];
export default function SessionBanner({
@@ -207,7 +209,7 @@ export default function SessionBanner({
setSettingsOpen(false)} />
{/* Settings modal */}
-
+
{/* Modal header */}
Settings
@@ -263,7 +265,7 @@ export default function SessionBanner({
{label}
{badge &&
{badge} }
{key === "showTeamAbbr" && mobileTeamAbbrHidden && (
-
Auto-hidden on mobile
+
Auto-hidden
)}
@@ -327,13 +329,16 @@ export default function SessionBanner({
>)}
{settingsTab === "Other" && (<>
- {OTHER_SETTINGS.map(({ key, label }) => (
+ {OTHER_SETTINGS.map(({ key, label, hint }) => (
onSettingChange?.(key, !settings[key])}
className="w-full flex items-center justify-between px-2 sm:px-6 py-1.5 hover:bg-white/5 transition-colors"
>
- {label}
+
+ {label}
+ {hint && {hint} }
+
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 || [],
diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts
index c3c9332..3244697 100644
--- a/frontend/src/hooks/useSettings.ts
+++ b/frontend/src/hooks/useSettings.ts
@@ -29,6 +29,8 @@ export interface ReplaySettings {
useImperial: boolean;
rcSound: boolean;
showCorners: boolean;
+ highContrast: boolean;
+ showAllPanels: boolean;
}
const STORAGE_KEY = "f1replay_settings";
@@ -60,6 +62,8 @@ export const DEFAULTS: ReplaySettings = {
useImperial: false,
rcSound: false,
showCorners: true,
+ highContrast: false,
+ showAllPanels: false,
};
function loadSettings(): ReplaySettings {
@@ -78,7 +82,11 @@ export function useSettings() {
const [settings, setSettings] = useState(DEFAULTS);
useEffect(() => {
- setSettings(loadSettings());
+ const loaded = loadSettings();
+ setSettings(loaded);
+ if (loaded.highContrast) {
+ document.documentElement.classList.add("high-contrast");
+ }
}, []);
const update = useCallback((key: keyof ReplaySettings, value: boolean) => {
@@ -87,6 +95,9 @@ export function useSettings() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {}
+ if (key === "highContrast") {
+ document.documentElement.classList.toggle("high-contrast", value);
+ }
return next;
});
}, []);
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index f0a1efb..ee523f0 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -11,7 +11,7 @@ const config: Config = {
surface: "#1A1A26",
card: "#1E1E2E",
border: "#2A2A3C",
- muted: "#6B7280",
+ muted: "var(--f1-muted)",
text: "#E5E7EB",
},
tyre: {