Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions backend/routers/replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 45 additions & 13 deletions backend/services/f1_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")),
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/services/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
@tailwind components;
@tailwind utilities;

:root {
--f1-muted: #6B7280;
}

.high-contrast {
--f1-muted: #FFFFFF;
}

html,
body {
margin: 0;
Expand Down
194 changes: 147 additions & 47 deletions frontend/src/app/live/[year]/[round]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -599,57 +599,157 @@ export default function LivePage() {
>
Delay: {delayOffset > 0 ? "+" : ""}{delayOffset}s
</button>
{showDelaySlider && (
<div className="absolute bottom-full right-0 mb-2 bg-f1-card border border-f1-border rounded-lg p-3 shadow-xl z-50 w-56">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-bold text-f1-muted uppercase">Broadcast Delay</span>
<button
onClick={() => { setDelayOffset(0); }}
className="text-[10px] text-f1-muted hover:text-white"
>
Reset
{showDelaySlider && (<>
{/* Modal backdrop */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={() => setShowDelaySlider(false)} />

{/* Delay modal */}
<div className="fixed inset-x-6 top-1/2 -translate-y-1/2 sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 z-50 sm:w-[360px] bg-[#1A1A26] border border-f1-border rounded-xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 sm:px-5 py-3 border-b border-f1-border">
<span className="text-sm font-bold text-white">Broadcast Delay</span>
<button onClick={() => setShowDelaySlider(false)} className="text-f1-muted hover:text-white">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<input
type="range"
min={-60}
max={10}
step={0.5}
value={delayOffset}
onChange={(e) => setDelayOffset(Number(e.target.value))}
className="w-full h-1 bg-f1-border rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div className="flex justify-between text-[9px] text-f1-muted mt-1">
<span>-60s</span>
<span>+10s</span>
</div>
<div className="flex items-center gap-2 mt-2">
<label className="text-[9px] text-f1-muted flex-shrink-0">Exact delay:</label>
<input
type="number"
min={-60}
max={10}
step={0.5}
value={delayOffset}
onChange={(e) => {
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"
/>
<span className="text-[9px] text-f1-muted">seconds</span>

<div className="px-3 sm:px-5 py-3 sm:py-4 space-y-3 sm:space-y-4">
{/* Current value display */}
<div className="text-center">
<span className="text-3xl font-extrabold text-white tabular-nums">{delayOffset.toFixed(1)}</span>
<span className="text-lg text-f1-muted ml-1">seconds</span>
</div>

{/* Slider */}
{/* Slider with zero tick mark */}
<div className="relative">
<input
type="range"
min={-60}
max={10}
step={0.5}
value={delayOffset}
onChange={(e) => 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 */}
<div
className="absolute pointer-events-none z-20"
style={{ left: `calc(${(60 / 70) * 100}% - 6px)`, top: "calc(50% + 3px)", transform: "translate(-50%, -50%)" }}
>
<div className="w-px h-4 bg-white/40" />
</div>
</div>
<div className="relative flex justify-between text-[10px] text-f1-muted mt-1">
<span>-60s</span>
<span className="absolute text-[10px]" style={{ left: `calc(${(60 / 70) * 100}% - 5px)`, transform: "translateX(-50%)" }}>0s</span>
<span>+10s</span>
</div>

{/* Quick adjust buttons */}
<div className="flex items-center justify-center gap-1">
{[
{ label: "-5s", delta: -5 },
{ label: "-1s", delta: -1 },
{ label: "-0.5s", delta: -0.5 },
].map(({ label, delta }) => (
<button
key={label}
onClick={() => 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}
</button>
))}
<span className="w-8" />
{[
{ label: "+0.5s", delta: 0.5 },
{ label: "+1s", delta: 1 },
{ label: "+5s", delta: 5 },
].map(({ label, delta }) => (
<button
key={label}
onClick={() => 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}
</button>
))}
</div>

{/* Manual input */}
<div className="flex items-center justify-center gap-2">
<label className="text-xs text-f1-muted">Exact:</label>
<div className="flex items-center bg-f1-dark border border-f1-border rounded overflow-hidden focus-within:border-blue-500">
<button
id="delay-sign-btn"
onClick={() => {
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"
>
</button>
<input
id="delay-exact-input"
type="text"
inputMode="decimal"
onFocus={(e) => { 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"
/>
</div>
<button
onClick={() => {
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
</button>
</div>

<p className="text-[10px] text-f1-muted leading-relaxed text-center">
Pauses the live data feed until it aligns with your broadcast. Set this to match the delay of your streaming service.
</p>

{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => { 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
</button>
<button
onClick={() => 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
</button>
</div>
</div>
<p className="text-[9px] text-f1-muted mt-2 leading-relaxed">
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.
</p>
<button
onClick={() => 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
</button>
</div>
)}
</>)}
</div>

{/* Live indicator + PiP */}
Expand Down
Loading
Loading