@@ -234,6 +237,11 @@ export function LiveTimingPage() {
/>
+
diff --git a/frontend/src/styles/delta-graph.css b/frontend/src/styles/delta-graph.css
new file mode 100644
index 0000000..e3fafd5
--- /dev/null
+++ b/frontend/src/styles/delta-graph.css
@@ -0,0 +1,76 @@
+.delta-graph {
+ width: 100%;
+ min-width: 280px;
+ max-width: 640px;
+}
+
+.delta-graph-svg {
+ display: block;
+ width: 100%;
+}
+
+.delta-graph-empty {
+ padding: 1.5rem 1rem;
+ text-align: center;
+ color: var(--text-3);
+ font-size: 0.875rem;
+}
+
+.delta-graph-zero-line {
+ stroke: var(--text-3);
+ stroke-width: 1;
+ stroke-dasharray: 4 3;
+}
+
+.delta-graph-grid-line {
+ stroke: var(--border);
+ stroke-width: 0.5;
+}
+
+.delta-graph-axis-label {
+ fill: var(--text-3);
+ font-family: var(--f-mono);
+ font-size: 9px;
+}
+
+.delta-graph-driver-line {
+ fill: none;
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+
+.delta-graph-crosshair {
+ stroke: var(--text-2);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+
+.delta-graph-hover-layer {
+ cursor: crosshair;
+ touch-action: none;
+}
+
+.delta-graph-tooltip {
+ pointer-events: none;
+}
+
+.delta-graph-tooltip-bg {
+ fill: var(--bg-elevated, var(--bg));
+ stroke: var(--border);
+ stroke-width: 1;
+}
+
+.delta-graph-tooltip-title {
+ fill: var(--text-1);
+ font-family: var(--f-mono);
+ font-size: 10px;
+ font-weight: 600;
+}
+
+.delta-graph-tooltip-row {
+ fill: var(--text-2);
+ font-family: var(--f-mono);
+ font-size: 9px;
+}
diff --git a/frontend/src/styles/hero.css b/frontend/src/styles/hero.css
new file mode 100644
index 0000000..7f1a8c1
--- /dev/null
+++ b/frontend/src/styles/hero.css
@@ -0,0 +1,373 @@
+.hero-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s4);
+}
+
+.hero-card {
+ display: flex;
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ overflow: hidden;
+ min-height: 124px;
+}
+
+.hero-accent {
+ width: 4px;
+ flex-shrink: 0;
+ box-shadow: 0 0 12px var(--hero-accent, var(--gp-accent));
+ background: var(--hero-accent, var(--gp-accent));
+}
+
+.hero-accent--live {
+ animation: hero-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes hero-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.55; }
+}
+
+.hero-body {
+ flex: 1;
+ padding: var(--s5);
+ min-width: 0;
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at top right, color-mix(in srgb, var(--gp-accent) 15%, transparent), transparent 60%),
+ linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ var(--surface);
+ background-size: 100% 100%, 20px 20px, 20px 20px, 100% 100%;
+}
+
+.hero-body::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
+ pointer-events: none;
+}
+
+.hero-inner {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s5);
+}
+
+.hero-row {
+ display: flex;
+ align-items: stretch;
+ gap: var(--s6);
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.hero-identity {
+ flex: 1;
+ min-width: 0;
+}
+
+.hero-decal {
+ font-size: 56px;
+ font-weight: 800;
+ line-height: 0.85;
+ letter-spacing: -0.04em;
+ color: color-mix(in srgb, var(--gp-accent) 35%, var(--text-3));
+ opacity: 0.9;
+ flex-shrink: 0;
+}
+
+.hero-eyebrow {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s2);
+}
+
+.hero-eyebrow--live {
+ color: var(--red);
+}
+
+.hero-title {
+ font-size: 28px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+ margin: 0;
+}
+
+.hero-sub {
+ font-size: 12px;
+ color: var(--text-2);
+ margin-top: var(--s2);
+}
+
+.hero-countdown {
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.hero-cd-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s2);
+}
+
+.hero-cd-value {
+ font-size: 22px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: var(--text);
+}
+
+.hero-cd-value--live {
+ color: var(--red);
+ font-size: 28px;
+ letter-spacing: 0.08em;
+}
+
+.hero-cd-sub {
+ font-size: 11px;
+ color: var(--text-3);
+ margin-top: var(--s2);
+}
+
+.hero-track-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: var(--s2) var(--s3);
+ border-radius: 4px;
+ margin-top: var(--s3);
+}
+
+.hero-track-status--green { color: #3dd68c; background: rgba(61, 214, 140, 0.1); }
+.hero-track-status--yellow { color: #f5c842; background: rgba(245, 200, 66, 0.1); }
+.hero-track-status--sc,
+.hero-track-status--vsc { color: #f5a623; background: rgba(245, 166, 35, 0.12); }
+.hero-track-status--red { color: var(--red); background: rgba(225, 6, 0, 0.12); }
+.hero-track-status--unknown { color: var(--text-3); background: rgba(255, 255, 255, 0.05); }
+
+.hero-timing {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ min-width: 200px;
+}
+
+.hero-timing-row {
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+ gap: var(--s3);
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.hero-timing-pos {
+ font-weight: 700;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.hero-timing-pos--p1 { color: #ffd700; }
+.hero-timing-pos--p2 { color: #c0c0c0; }
+.hero-timing-pos--p3 { color: #cd7f32; }
+
+.hero-timing-bar {
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.hero-timing-driver {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-weight: 600;
+ font-family: var(--f-mono);
+}
+
+.hero-timing-gap {
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--f-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.hero-cta {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--red);
+ text-decoration: none;
+ transition: opacity 0.1s;
+}
+
+.hero-cta:hover {
+ opacity: 0.85;
+}
+
+.hero-cta--primary {
+ padding: var(--s3) var(--s5);
+ background: rgba(225, 6, 0, 0.12);
+ border: 1px solid rgba(225, 6, 0, 0.35);
+ border-radius: 6px;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s3);
+ align-items: center;
+}
+
+.hero-secondary-link {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-2);
+ text-decoration: none;
+}
+
+.hero-secondary-link:hover {
+ color: var(--text);
+}
+
+.hero-schedule-strip {
+ display: flex;
+ gap: var(--s3);
+ overflow-x: auto;
+ padding-bottom: var(--s2);
+}
+
+.hero-schedule-card {
+ flex: 0 0 auto;
+ min-width: 120px;
+ padding: var(--s3) var(--s4);
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.1s, border-color 0.1s;
+}
+
+.hero-schedule-card:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.hero-schedule-card.is-next {
+ border-color: color-mix(in srgb, var(--gp-accent) 50%, transparent);
+ background: color-mix(in srgb, var(--gp-accent) 8%, transparent);
+}
+
+.hero-schedule-card.is-done {
+ opacity: 0.55;
+}
+
+.hero-schedule-card.is-live {
+ border-color: rgba(225, 6, 0, 0.4);
+ background: rgba(225, 6, 0, 0.08);
+}
+
+.hero-schedule-abbrev {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ color: var(--text-3);
+}
+
+.hero-schedule-name {
+ font-size: 12px;
+ font-weight: 600;
+ margin: var(--s2) 0;
+}
+
+.hero-schedule-time {
+ font-size: 10px;
+ color: var(--text-3);
+}
+
+.hero-schedule-marker {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ margin-top: var(--s2);
+ color: var(--text-3);
+}
+
+.hero-schedule-marker--next { color: var(--gp-accent); }
+.hero-schedule-marker--live { color: var(--red); }
+
+.hero-podium {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ min-width: 200px;
+}
+
+.hero-podium-row {
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ align-items: center;
+ gap: var(--s3);
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.hero-podium-pos {
+ font-weight: 700;
+ font-family: var(--f-mono);
+ color: var(--text-3);
+}
+
+.hero-podium-pos--p1 { color: #ffd700; }
+.hero-podium-pos--p2 { color: #c0c0c0; }
+.hero-podium-pos--p3 { color: #cd7f32; }
+
+.hero-podium-driver {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-weight: 600;
+ font-family: var(--f-mono);
+}
+
+.hero-between-next {
+ display: flex;
+ align-items: center;
+ gap: var(--s4);
+}
+
+.hero-flag {
+ font-size: 32px;
+ line-height: 1;
+}
+
+@media (min-width: 700px) {
+ .hero-row {
+ flex-wrap: nowrap;
+ }
+}
diff --git a/frontend/src/styles/stint-timeline.css b/frontend/src/styles/stint-timeline.css
new file mode 100644
index 0000000..38e9219
--- /dev/null
+++ b/frontend/src/styles/stint-timeline.css
@@ -0,0 +1,94 @@
+.stint-timeline {
+ --stint-left: 48px;
+ --stint-right: 12px;
+ --stint-row-h: 28px;
+ --stint-bar-h: 14px;
+ --stint-axis-h: 20px;
+}
+
+.stint-timeline__scroll {
+ overflow-x: auto;
+}
+
+.stint-timeline__svg {
+ width: 100%;
+ min-width: 280px;
+ max-width: 640px;
+ display: block;
+}
+
+.stint-timeline__label {
+ font-family: var(--f-mono);
+ font-weight: 700;
+ font-size: 10px;
+}
+
+.stint-timeline__bar {
+ stroke: none;
+}
+
+.stint-timeline__bar.tyre-soft { fill: var(--tyre-soft); }
+.stint-timeline__bar.tyre-medium { fill: var(--tyre-medium); }
+.stint-timeline__bar.tyre-hard { fill: var(--tyre-hard); stroke: #555; stroke-width: 0.5; }
+.stint-timeline__bar.tyre-inter { fill: var(--tyre-inter); }
+.stint-timeline__bar.tyre-wet { fill: var(--tyre-wet); }
+.stint-timeline__bar.tyre-unknown { fill: var(--surface-2); stroke: var(--border-2); stroke-width: 0.5; }
+
+.stint-timeline__bar--new {
+ stroke: var(--text);
+ stroke-width: 1;
+ stroke-dasharray: 2 1;
+}
+
+.stint-timeline__axis-tick {
+ font-family: var(--f-mono);
+ font-size: 9px;
+ fill: var(--text-3);
+}
+
+.stint-timeline__axis-line {
+ stroke: var(--border-2);
+ stroke-width: 1;
+}
+
+.stint-timeline__legend {
+ display: flex;
+ gap: var(--s4);
+ flex-wrap: wrap;
+ margin-top: var(--s4);
+ font-size: 11px;
+ color: var(--text-3);
+ align-items: center;
+}
+
+.stint-timeline__legend-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ display: inline-block;
+}
+
+.stint-timeline__legend-swatch.tyre-hard {
+ border: 1px solid #555;
+}
+
+.stint-timeline__legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.stint-timeline__meta {
+ margin-left: auto;
+ font-family: var(--f-mono);
+ font-size: 10px;
+}
+
+.stint-timeline__empty {
+ padding: var(--s5);
+ color: var(--text-3);
+ font-size: 13px;
+ text-align: center;
+ border: 1px dashed var(--border-2);
+ border-radius: var(--r2);
+}
diff --git a/frontend/src/styles/team-radio.css b/frontend/src/styles/team-radio.css
new file mode 100644
index 0000000..152a405
--- /dev/null
+++ b/frontend/src/styles/team-radio.css
@@ -0,0 +1,82 @@
+.team-radio {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.team-radio-audio {
+ display: none;
+}
+
+.team-radio-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 260px;
+ overflow-y: auto;
+ padding-right: 2px;
+}
+
+.team-radio-row {
+ display: grid;
+ grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr);
+ align-items: center;
+ gap: 8px;
+ min-height: 34px;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.team-radio-row:last-child {
+ border-bottom: 0;
+}
+
+.team-radio-play {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 50%;
+ color: var(--text-1);
+ background: rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+}
+
+.team-radio-play:hover:not(:disabled),
+.team-radio-play:focus-visible:not(:disabled) {
+ border-color: rgba(255, 255, 255, 0.36);
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.team-radio-play:disabled {
+ color: var(--text-3);
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.team-radio-driver {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 44px;
+ height: 24px;
+ border-left: 3px solid var(--text-3);
+ color: var(--text-1);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+.team-radio-time {
+ min-width: 0;
+ color: var(--text-2);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ text-align: right;
+ white-space: nowrap;
+}
diff --git a/frontend/src/styles/telemetry-trace.css b/frontend/src/styles/telemetry-trace.css
new file mode 100644
index 0000000..87b316f
--- /dev/null
+++ b/frontend/src/styles/telemetry-trace.css
@@ -0,0 +1,101 @@
+.telemetry-trace {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.telemetry-trace-empty {
+ padding: 24px;
+ text-align: center;
+ color: var(--text-3);
+ font-size: 13px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.telemetry-trace-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ font-size: 12px;
+ color: var(--text-2);
+}
+
+.telemetry-trace-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.telemetry-trace-swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ flex: none;
+}
+
+.telemetry-trace-svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.telemetry-trace-panel-bg {
+ fill: var(--surface);
+ stroke: var(--border);
+ stroke-width: 1;
+ rx: 4;
+}
+
+.telemetry-trace-panel-title {
+ fill: var(--text-2);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.telemetry-trace-axis-max {
+ fill: var(--text-3);
+ font-size: 10px;
+}
+
+.telemetry-trace-crosshair {
+ stroke: var(--text-2);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ pointer-events: none;
+}
+
+.telemetry-trace-readout {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--text-2);
+ background: var(--surface-h);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+
+.telemetry-trace-readout-index {
+ color: var(--text-3);
+}
+
+.telemetry-trace-readout-driver {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.telemetry-trace-readout-label {
+ color: var(--text);
+ font-weight: 600;
+}
+
+.telemetry-trace-readout-value {
+ font-variant-numeric: tabular-nums;
+}
diff --git a/frontend/src/styles/tyredeg.css b/frontend/src/styles/tyredeg.css
new file mode 100644
index 0000000..c6d6950
--- /dev/null
+++ b/frontend/src/styles/tyredeg.css
@@ -0,0 +1,84 @@
+/* Live tyre-degradation & pit-window panel (see components/live/TyreDegPanel.tsx) */
+
+.live-tyredeg-panel {
+ margin-bottom: var(--s5);
+}
+
+.tyredeg-toggle {
+ width: 100%;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ text-align: left;
+}
+
+.tyredeg-chevron {
+ margin-left: auto;
+ font-size: 10px;
+ color: var(--text-3);
+}
+
+.tyredeg-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.tyredeg-row {
+ display: flex;
+ align-items: center;
+ gap: var(--s4);
+ padding: var(--s2) var(--s4);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ font-size: 12px;
+}
+
+.tyredeg-row .drv-bar { height: 16px; }
+
+.tyredeg-pos {
+ min-width: 28px;
+ color: var(--text-2);
+ font-size: 11px;
+}
+
+.tyredeg-spark { display: block; }
+.tyredeg-spark-empty { color: var(--text-3); }
+
+.tyredeg-trend { display: inline-flex; align-items: center; }
+.tyredeg-trend-improving { color: var(--green); }
+.tyredeg-trend-stable { color: var(--text-3); }
+.tyredeg-trend-degrading { color: #ff6b6b; }
+
+.tyredeg-slope {
+ min-width: 78px;
+ font-size: 11px;
+}
+
+.tyredeg-warmup {
+ color: var(--text-3);
+ font-size: 11px;
+ font-style: italic;
+}
+
+.tyredeg-rejoin {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: baseline;
+ gap: var(--s3);
+ color: var(--text-2);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.tyredeg-rejoin-between {
+ color: var(--text-3);
+ font-size: 10px;
+}
+
+@media (max-width: 640px) {
+ .tyredeg-rejoin-between { display: none; }
+ .tyredeg-slope { min-width: 0; }
+}
diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx
index 4ec99f8..1519c59 100644
--- a/frontend/src/test/CommandCenterPage.test.tsx
+++ b/frontend/src/test/CommandCenterPage.test.tsx
@@ -12,9 +12,20 @@ vi.mock('../api', () => ({
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
+ fetchChampionshipHub: vi.fn(),
+ fetchRaceHub: vi.fn(),
}))
-import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
+import {
+ fetchSeasons,
+ fetchLocalMeetings,
+ fetchSeasonMeetings,
+ fetchSessions,
+ fetchWeekend,
+ fetchLiveState,
+ fetchChampionshipHub,
+ fetchRaceHub,
+} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
@@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
+const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
+const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const meeting: Meeting = {
meeting_key: 1229,
@@ -102,6 +115,48 @@ describe('CommandCenterPage', () => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
+ mockFetchChampionshipHub.mockResolvedValue({
+ season: 2025,
+ round: 1,
+ total_rounds: 1,
+ rounds_left: 0,
+ last_race: 'Monaco',
+ round_labels: ['R1'],
+ drivers: [],
+ teams: [],
+ })
+ mockFetchRaceHub.mockResolvedValue({
+ source: 'local',
+ session_key: 9472,
+ datasets: fullDatasets,
+ results: [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7200,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ ],
+ starting_grid: [],
+ drivers: [],
+ stints: [],
+ pit_stops: [],
+ positions: [],
+ race_control: [],
+ weather: [],
+ laps: [],
+ })
})
it('shows empty state when no seasons are ingested', async () => {
@@ -131,11 +186,10 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
- expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
- expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
+ expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
@@ -181,9 +235,9 @@ describe('CommandCenterPage', () => {
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
- expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
+ expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
- expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
+ expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})
diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx
index c3df461..7af18bc 100644
--- a/frontend/src/test/LiveComponents.test.tsx
+++ b/frontend/src/test/LiveComponents.test.tsx
@@ -201,6 +201,7 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
+ Path: '',
}}
/>,
)
diff --git a/frontend/src/test/TeamRadioTicker.test.tsx b/frontend/src/test/TeamRadioTicker.test.tsx
new file mode 100644
index 0000000..02569a1
--- /dev/null
+++ b/frontend/src/test/TeamRadioTicker.test.tsx
@@ -0,0 +1,87 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
+import { radioClipUrl } from '../lib/radio'
+import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types'
+
+const session: LiveSessionMeta = {
+ MeetingName: 'British Grand Prix',
+ CircuitName: 'Silverstone',
+ SessionType: 'Race',
+ SessionName: 'Race',
+ Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/',
+}
+
+const driverInfo: Record
= {
+ '4': {
+ RacingNumber: '4',
+ BroadcastName: 'L NORRIS',
+ Tla: 'NOR',
+ TeamName: 'McLaren',
+ TeamColour: 'ff8000',
+ FirstName: 'Lando',
+ LastName: 'Norris',
+ },
+ '16': {
+ RacingNumber: '16',
+ BroadcastName: 'C LECLERC',
+ Tla: 'LEC',
+ TeamName: 'Ferrari',
+ TeamColour: 'e8002d',
+ FirstName: 'Charles',
+ LastName: 'Leclerc',
+ },
+}
+
+const captures: LiveRadioCapture[] = [
+ { Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' },
+ { Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' },
+]
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('radioClipUrl', () => {
+ it('builds static CDN URLs from the session path and capture path', () => {
+ expect(radioClipUrl(session, captures[0])).toBe(
+ 'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3',
+ )
+ })
+
+ it('returns an empty URL without path data', () => {
+ expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('')
+ expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('')
+ })
+})
+
+describe('TeamRadioTicker', () => {
+ it('renders captures newest first with driver labels', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z'))
+
+ render()
+
+ const rows = screen.getAllByRole('button')
+ expect(rows).toHaveLength(2)
+ expect(screen.getByText('NOR')).toBeInTheDocument()
+ expect(screen.getByText('LEC')).toBeInTheDocument()
+ expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago'])
+ })
+
+ it('uses one audio element and toggles play/pause per clip', () => {
+ const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
+ const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
+
+ render()
+
+ fireEvent.click(screen.getByLabelText('Play NOR radio'))
+ expect(play).toHaveBeenCalledTimes(1)
+ expect(document.querySelectorAll('audio')).toHaveLength(1)
+ expect(document.querySelector('audio')?.getAttribute('src')).toBe(
+ 'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3',
+ )
+
+ fireEvent.click(screen.getByLabelText('Pause NOR radio'))
+ expect(pause).toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/test/TyreDegPanel.test.tsx b/frontend/src/test/TyreDegPanel.test.tsx
new file mode 100644
index 0000000..1bb9eff
--- /dev/null
+++ b/frontend/src/test/TyreDegPanel.test.tsx
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TyreDegPanel } from '../components/live/TyreDegPanel'
+import type { LiveTimingRow } from '../lib/live'
+import type { LiveDriverData, LiveTyreData } from '../types'
+
+function makeRow(
+ number: string,
+ position: number,
+ tla: string,
+ driver: Partial = {},
+ tyre: Partial = {},
+): LiveTimingRow {
+ return {
+ RacingNumber: number,
+ Position: position,
+ Driver: {
+ RacingNumber: number,
+ Position: position,
+ Interval: position === 1 ? '' : '+5.0',
+ GapToLeader: '',
+ LastLapTime: '1:30.000',
+ NumberOfLaps: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ ...driver,
+ } as LiveDriverData,
+ Info: {
+ RacingNumber: number,
+ BroadcastName: '',
+ Tla: tla,
+ TeamName: '',
+ TeamColour: '3671c6',
+ FirstName: '',
+ LastName: '',
+ },
+ Tyre: { Compound: 'MEDIUM', New: false, Age: 5, ...tyre } as LiveTyreData,
+ }
+}
+
+function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
+ return [
+ makeRow('1', 1, 'VER', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
+ makeRow('4', 2, 'NOR', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
+ ]
+}
+
+describe('TyreDegPanel', () => {
+ it('shows a warming-up placeholder until enough clean laps accumulate', () => {
+ render()
+ const panel = screen.getByTestId('tyredeg-panel')
+ expect(panel).toHaveTextContent('VER')
+ expect(panel).toHaveTextContent('M +5')
+ expect(panel).toHaveTextContent('warming up')
+ })
+
+ it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
+ const { rerender } = render(
+ ,
+ )
+ // Five completed laps at +0.1s/lap after the first observed snapshot.
+ for (let lap = 2; lap <= 6; lap++) {
+ const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
+ rerender()
+ }
+
+ const panel = screen.getByTestId('tyredeg-panel')
+ expect(panel).toHaveTextContent('+0.10s/lap')
+ expect(panel).not.toHaveTextContent('warming up')
+ // VER pits from P1 with NOR +5.0 behind: NOR gets past -> rejoin ~P2.
+ expect(panel).toHaveTextContent('→ ~P2')
+ })
+
+ it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
+ render()
+ const panel = screen.getByTestId('tyredeg-panel')
+ expect(panel).not.toHaveTextContent('~P')
+
+ expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
+ fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
+ expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
+ })
+
+ it('limits rows to the top ten plus pinned drivers', () => {
+ const rows = Array.from({ length: 15 }, (_, index) =>
+ makeRow(String(index + 1), index + 1, `D${index + 1}`),
+ )
+ render()
+ expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
+ expect(screen.getByText('D14')).toBeInTheDocument()
+ expect(screen.queryByText('D12')).not.toBeInTheDocument()
+ })
+
+ it('renders nothing without rows', () => {
+ render()
+ expect(screen.queryByTestId('tyredeg-panel')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/delta-time-graph.test.tsx b/frontend/src/test/delta-time-graph.test.tsx
new file mode 100644
index 0000000..291d136
--- /dev/null
+++ b/frontend/src/test/delta-time-graph.test.tsx
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph'
+import {
+ computeCumulativeDeltas,
+ deltaPolylineSegments,
+ formatDeltaSeconds,
+ type DeltaSeries,
+} from '../lib/delta'
+
+const reference: DeltaSeries = {
+ label: 'VER',
+ color: '#3671C6',
+ lapTimes: [90, 91, 92],
+}
+
+const challenger: DeltaSeries = {
+ label: 'HAM',
+ color: '#E8002D',
+ lapTimes: [89, 92, 90],
+}
+
+describe('formatDeltaSeconds', () => {
+ it('formats signed deltas with one decimal and s suffix', () => {
+ expect(formatDeltaSeconds(2.5)).toBe('+2.5s')
+ expect(formatDeltaSeconds(-1.2)).toBe('-1.2s')
+ expect(formatDeltaSeconds(0)).toBe('+0.0s')
+ })
+})
+
+describe('computeCumulativeDeltas', () => {
+ it('computes cumulative delta vs the first series by default', () => {
+ const result = computeCumulativeDeltas([reference, challenger])
+ expect(result).toHaveLength(1)
+ expect(result[0].label).toBe('HAM')
+ // Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2
+ expect(result[0].deltas[0]).toBeCloseTo(-1)
+ expect(result[0].deltas[1]).toBeCloseTo(0)
+ expect(result[0].deltas[2]).toBeCloseTo(-2)
+ })
+
+ it('respects an explicit reference label', () => {
+ const result = computeCumulativeDeltas([reference, challenger], 'HAM')
+ expect(result).toHaveLength(1)
+ expect(result[0].label).toBe('VER')
+ expect(result[0].deltas[0]).toBeCloseTo(1)
+ expect(result[0].deltas[2]).toBeCloseTo(2)
+ })
+
+ it('emits null for missing lap times while carrying cumulative forward', () => {
+ const withNull: DeltaSeries = {
+ label: 'NOR',
+ color: '#FF8000',
+ lapTimes: [88, null, 93],
+ }
+ const result = computeCumulativeDeltas([reference, withNull])
+ expect(result[0].deltas[0]).toBeCloseTo(-2)
+ expect(result[0].deltas[1]).toBeNull()
+ // After null: NOR cum=181, VER cum=273 → delta -92
+ expect(result[0].deltas[2]).toBeCloseTo(-92)
+ })
+
+ it('returns an empty array when only one series is provided', () => {
+ expect(computeCumulativeDeltas([reference])).toEqual([])
+ })
+})
+
+describe('deltaPolylineSegments', () => {
+ it('splits polylines at null laps', () => {
+ const segments = deltaPolylineSegments(
+ [1, null, 2],
+ (lap, delta) => `${lap},${delta}`,
+ )
+ expect(segments).toEqual(['0,1', '2,2'])
+ })
+})
+
+describe('DeltaTimeGraph', () => {
+ it('renders an empty state with no series', () => {
+ render()
+ expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
+ expect(screen.getByText(/No lap data/i)).toBeInTheDocument()
+ })
+
+ it('renders an empty state with only the reference driver', () => {
+ render()
+ expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
+ expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument()
+ })
+
+ it('renders a zero line and one polyline per non-reference driver', () => {
+ const { container } = render()
+ expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
+ expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument()
+ expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument()
+ expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1)
+ expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
+ })
+
+ it('shows a crosshair tooltip on hover', () => {
+ vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ width: 640,
+ height: 220,
+ right: 640,
+ bottom: 220,
+ toJSON: () => ({}),
+ })
+ const { container } = render()
+ const hoverLayer = container.querySelector('.delta-graph-hover-layer')
+ expect(hoverLayer).toBeTruthy()
+ fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 })
+ expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
+ expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument()
+ expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
+ vi.restoreAllMocks()
+ })
+})
diff --git a/frontend/src/test/hero.test.ts b/frontend/src/test/hero.test.ts
new file mode 100644
index 0000000..21af353
--- /dev/null
+++ b/frontend/src/test/hero.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect } from 'vitest'
+import { heroState, classifySessionStatus } from '../lib/hero'
+import { currentAndNextSession } from '../lib/schedule'
+import type { Session } from '../types'
+
+const session = (overrides: Partial = {}): Session => ({
+ session_key: 9472,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1,
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ ...overrides,
+})
+
+describe('heroState', () => {
+ it('returns live when the live snapshot is active', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: true,
+ currentSession: null,
+ focusKind: 'current',
+ }),
+ ).toBe('live')
+ })
+
+ it('returns live when a session is in progress', () => {
+ const now = new Date('2025-05-25T14:00:00Z')
+ const sessions = [session()]
+ const { current } = currentAndNextSession(sessions, now)
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('live')
+ })
+
+ it('returns upcoming during a focus weekend gap before the next session', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ const sessions = [
+ session({
+ session_key: 1,
+ session_name: 'FP1',
+ date_start: '2025-05-23T10:00:00+00:00',
+ date_end: '2025-05-23T11:00:00+00:00',
+ }),
+ session({
+ session_key: 2,
+ session_name: 'Race',
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ }),
+ ]
+ const { current, next } = currentAndNextSession(sessions, now)
+ expect(current).toBeNull()
+ expect(next?.session_key).toBe(2)
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('upcoming')
+ })
+
+ it('returns upcoming when the weekend just ended but is still the focus weekend', () => {
+ const now = new Date('2025-05-25T16:00:00Z')
+ const sessions = [session()]
+ const { current } = currentAndNextSession(sessions, now)
+ expect(current).toBeNull()
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('upcoming')
+ })
+
+ it('returns between before the next grand prix weekend', () => {
+ const now = new Date('2025-06-01T12:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: null,
+ focusKind: 'next',
+ }),
+ ).toBe('between')
+ })
+
+ it('returns between after the season when only a recent meeting remains', () => {
+ const now = new Date('2026-01-01T00:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: null,
+ focusKind: 'recent',
+ }),
+ ).toBe('between')
+ })
+})
+
+describe('classifySessionStatus', () => {
+ it('marks a session as done immediately after it ends', () => {
+ const now = new Date('2025-05-25T15:00:00Z')
+ expect(classifySessionStatus(session(), now)).toBe('done')
+ })
+
+ it('marks a session as live during its window', () => {
+ const now = new Date('2025-05-25T14:00:00Z')
+ expect(classifySessionStatus(session(), now)).toBe('live')
+ })
+})
diff --git a/frontend/src/test/hero.test.tsx b/frontend/src/test/hero.test.tsx
new file mode 100644
index 0000000..3db06d1
--- /dev/null
+++ b/frontend/src/test/hero.test.tsx
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi } from 'vitest'
+import type { ComponentProps, ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types'
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ to,
+ children,
+ ...rest
+ }: {
+ to: string
+ children: ReactNode
+ className?: string
+ 'data-testid'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+import { CommandCenterHero } from '../components/CommandCenterHero'
+
+const focusMeeting: Meeting = {
+ meeting_key: 1229,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_short_name: 'Monaco',
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T00:00:00+00:00',
+ year: 2025,
+}
+
+const weekendSessions: WeekendSession[] = [
+ {
+ session: {
+ session_key: 9472,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1229,
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ },
+ source: 'local',
+ datasets: {} as WeekendSession['datasets'],
+ },
+]
+
+const liveData: LiveStreamData = {
+ Drivers: {
+ '1': {
+ RacingNumber: '1',
+ Position: 1,
+ PrevPosition: 1,
+ GapToLeader: '',
+ Interval: '',
+ LastLapTime: '1:14.000',
+ LastLapPB: false,
+ LastLapOB: false,
+ BestLapTime: '1:13.500',
+ BestLapPB: false,
+ BestLapOB: false,
+ BestLapNum: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ KnockedOut: false,
+ Cutoff: false,
+ OnFlyingLap: false,
+ NumberOfLaps: 10,
+ SpeedTrap: '',
+ Sectors: [],
+ },
+ '44': {
+ RacingNumber: '44',
+ Position: 2,
+ PrevPosition: 2,
+ GapToLeader: '+1.2',
+ Interval: '+1.2',
+ LastLapTime: '1:14.200',
+ LastLapPB: false,
+ LastLapOB: false,
+ BestLapTime: '1:13.700',
+ BestLapPB: false,
+ BestLapOB: false,
+ BestLapNum: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ KnockedOut: false,
+ Cutoff: false,
+ OnFlyingLap: false,
+ NumberOfLaps: 10,
+ SpeedTrap: '',
+ Sectors: [],
+ },
+ },
+ DriverInfo: {
+ '1': {
+ RacingNumber: '1',
+ BroadcastName: 'M VERSTAPPEN',
+ Tla: 'VER',
+ TeamName: 'Red Bull Racing',
+ TeamColour: '3671c6',
+ FirstName: 'Max',
+ LastName: 'Verstappen',
+ },
+ '44': {
+ RacingNumber: '44',
+ BroadcastName: 'L HAMILTON',
+ Tla: 'HAM',
+ TeamName: 'Ferrari',
+ TeamColour: 'e8002d',
+ FirstName: 'Lewis',
+ LastName: 'Hamilton',
+ },
+ },
+ Tyres: {},
+ RCMessages: [],
+ Weather: {
+ AirTemp: 20,
+ TrackTemp: 30,
+ Humidity: 50,
+ WindSpeed: 1,
+ WindDir: 0,
+ Rainfall: false,
+ },
+ Session: {
+ MeetingName: 'Monaco',
+ CircuitName: 'Monaco',
+ SessionType: 'Race',
+ SessionName: 'Race',
+ },
+ TrackStatus: '1',
+ CurrentLap: 10,
+ TotalLaps: 78,
+ Clock: '',
+ ClockRefTime: '',
+ ClockExtrapolating: false,
+ Stints: {},
+}
+
+const podium: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7200,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ {
+ driver_number: 44,
+ position: 2,
+ name_acronym: 'HAM',
+ full_name: 'Lewis Hamilton',
+ team_name: 'Ferrari',
+ team_colour: 'e8002d',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7205,
+ gap_to_leader: 3.2,
+ number_of_laps: 78,
+ points: 18,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+]
+
+function renderHero(overrides: Partial> = {}) {
+ const props: ComponentProps = {
+ state: 'upcoming',
+ now: new Date('2025-05-24T12:00:00Z'),
+ accent: '#d61a3e',
+ liveActive: false,
+ liveData: null,
+ focusMeeting,
+ focusKind: 'current',
+ sessions: weekendSessions,
+ currentSession: null,
+ nextSession: weekendSessions[0].session,
+ lastRaceName: '',
+ lastRacePodium: [],
+ nextMeeting: null,
+ ...overrides,
+ }
+
+ return render()
+}
+
+describe('CommandCenterHero', () => {
+ it('renders live timing link and top three in live state', () => {
+ renderHero({
+ state: 'live',
+ liveActive: true,
+ liveData,
+ currentSession: weekendSessions[0].session,
+ })
+
+ expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
+ expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER')
+ expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM')
+ expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument()
+ })
+
+ it('renders countdown and schedule strip in upcoming state', () => {
+ renderHero({
+ state: 'upcoming',
+ nextSession: weekendSessions[0].session,
+ })
+
+ expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s')
+ expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race')
+ expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next')
+ })
+
+ it('renders last race podium and next GP countdown in between state', () => {
+ const nextMeeting: Meeting = {
+ ...focusMeeting,
+ meeting_key: 1301,
+ meeting_name: 'Canada',
+ country_code: 'CAN',
+ date_start: '2025-06-06T00:00:00+00:00',
+ date_end: '2025-06-08T00:00:00+00:00',
+ }
+
+ renderHero({
+ state: 'between',
+ lastRaceName: 'Monaco',
+ lastRacePodium: podium,
+ nextMeeting,
+ now: new Date('2025-06-01T12:00:00Z'),
+ })
+
+ expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco')
+ expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER')
+ expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM')
+ expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada')
+ expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d')
+ })
+})
diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts
index 28b90ef..68b331b 100644
--- a/frontend/src/test/live.test.ts
+++ b/frontend/src/test/live.test.ts
@@ -100,7 +100,8 @@ const snapshot: LiveStreamData = {
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
],
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
- Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
+ Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' },
+ TeamRadio: [],
TrackStatus: '1',
CurrentLap: 21,
TotalLaps: 78,
@@ -220,7 +221,7 @@ 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' },
+ { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' },
rows(22),
)
expect(display.phaseLabel).toBe('SQ1')
@@ -232,7 +233,7 @@ describe('live qualifying display', () => {
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' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
rows(20),
)
expect(display.phaseLabel).toBe('Q1')
@@ -241,7 +242,7 @@ describe('live qualifying display', () => {
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' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
rows(20, 5),
)
expect(display.phaseLabel).toBe('Q2')
@@ -251,13 +252,13 @@ describe('live qualifying display', () => {
it('shows no cutoff for race sessions or Q3', () => {
expect(
liveSessionDisplay(
- { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' },
rows(20),
).cutoffPosition,
).toBeNull()
expect(
liveSessionDisplay(
- { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
+ { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' },
rows(10),
).cutoffPosition,
).toBeNull()
diff --git a/frontend/src/test/telemetry-trace-chart.test.tsx b/frontend/src/test/telemetry-trace-chart.test.tsx
new file mode 100644
index 0000000..1281cb7
--- /dev/null
+++ b/frontend/src/test/telemetry-trace-chart.test.tsx
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import {
+ TelemetryTraceChart,
+ type TelemetryTraceSeries,
+} from '../components/charts/TelemetryTraceChart'
+
+function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries {
+ return {
+ label,
+ color,
+ samples: speeds.map((speed, i) => ({
+ speed,
+ throttle: (i * 25) % 101,
+ brake: i % 2 === 0 ? 100 : 0,
+ })),
+ }
+}
+
+const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240])
+const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235])
+
+describe('TelemetryTraceChart', () => {
+ it('renders one polyline per series per channel', () => {
+ const { container } = render()
+ const lines = container.querySelectorAll('polyline.telemetry-trace-line')
+ expect(lines).toHaveLength(2 * 3)
+ for (const channel of ['speed', 'throttle', 'brake']) {
+ expect(
+ container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`),
+ ).toHaveLength(2)
+ }
+ expect(
+ container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'),
+ ).toHaveLength(3)
+ })
+
+ it('respects the channels prop', () => {
+ const { container } = render()
+ expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1)
+ expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1)
+ })
+
+ it('renders an empty state for no series', () => {
+ render()
+ expect(screen.getByText('No telemetry data')).toBeInTheDocument()
+ })
+
+ it('renders an empty state when a series has no samples', () => {
+ render()
+ expect(screen.getByText('No telemetry data')).toBeInTheDocument()
+ })
+
+ it('survives a single sample and mismatched series lengths', () => {
+ const short = makeSeries('VER', '#2563eb', [300])
+ const long = makeSeries('LEC', '#dc2626', [280, 290, 300])
+ const { container } = render()
+ // Clamped to the shortest series: every polyline has exactly one point.
+ const lines = container.querySelectorAll('polyline.telemetry-trace-line')
+ expect(lines).toHaveLength(6)
+ for (const line of lines) {
+ expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1)
+ }
+ })
+
+ it('clamps throttle values to 0-100 within the panel', () => {
+ const wild: TelemetryTraceSeries = {
+ label: 'VER',
+ color: '#2563eb',
+ samples: [
+ { speed: 100, throttle: 150, brake: 0 },
+ { speed: 100, throttle: -20, brake: 0 },
+ { speed: 100, throttle: 50, brake: 0 },
+ ],
+ }
+ const { container } = render(
+ ,
+ )
+ const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')!
+ const ys = line
+ .getAttribute('points')!
+ .split(' ')
+ .map((p) => Number(p.split(',')[1]))
+ // Panel occupies y 0..110 with 6px padding; clamped values pin to the edges.
+ expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top
+ expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom
+ expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle
+ })
+
+ it('shows the speed axis max in km/h', () => {
+ render()
+ expect(screen.getByText('310 km/h')).toBeInTheDocument()
+ })
+
+ it('lists every series in the legend with a color swatch', () => {
+ const { container } = render()
+ const legend = container.querySelector('.telemetry-trace-legend')!
+ expect(legend.textContent).toContain('VER')
+ expect(legend.textContent).toContain('LEC')
+ expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2)
+ })
+
+ it('shows a crosshair and per-driver readout on hover, hides on leave', () => {
+ const { container } = render()
+ const svg = container.querySelector('svg.telemetry-trace-svg')!
+ expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
+
+ fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 })
+ expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument()
+ const readout = screen.getByTestId('telemetry-trace-readout')
+ expect(readout.textContent).toContain('VER')
+ expect(readout.textContent).toContain('LEC')
+ expect(readout.textContent).toContain('km/h')
+
+ fireEvent.mouseLeave(svg)
+ expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/tyre-stint-timeline.test.tsx b/frontend/src/test/tyre-stint-timeline.test.tsx
new file mode 100644
index 0000000..b090c2e
--- /dev/null
+++ b/frontend/src/test/tyre-stint-timeline.test.tsx
@@ -0,0 +1,149 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { TyreStintTimeline, type StintTimelineRow } from '../components/charts/TyreStintTimeline'
+import { StrategyView } from '../components/StrategyView'
+import type { EnrichedResult, Stint, PitStop } from '../types'
+
+const sampleRows: StintTimelineRow[] = [
+ {
+ label: 'VER',
+ color: '#3671C6',
+ stints: [
+ { compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
+ { compound: 'SOFT', lapStart: 31, lapEnd: 78 },
+ ],
+ },
+ {
+ label: 'HAM',
+ color: '#E8002D',
+ stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
+ },
+]
+
+describe('TyreStintTimeline', () => {
+ it('renders one rect per stint', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
+ })
+
+ it('maps compounds to color classes', () => {
+ const { container } = render(
+ ,
+ )
+ const bars = container.querySelectorAll('.stint-timeline__bar')
+ expect(bars[0]).toHaveClass('tyre-medium')
+ expect(bars[1]).toHaveClass('tyre-soft')
+ expect(bars[2]).toHaveClass('tyre-soft')
+ })
+
+ it('shows native title with compound, lap range, and stint length', () => {
+ const { container } = render(
+ ,
+ )
+ const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
+ expect(titles).toContain('Medium · L1–30 · 30 laps')
+ expect(titles).toContain('Soft · L31–78 · 48 laps')
+ expect(titles).toContain('Soft · L1–18 · 18 laps')
+ })
+
+ it('renders lap-axis ticks every 10 laps', () => {
+ render()
+ expect(screen.getByText('0')).toBeInTheDocument()
+ expect(screen.getByText('10')).toBeInTheDocument()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ expect(screen.getByText('70')).toBeInTheDocument()
+ })
+
+ it('shows legend only for used compounds', () => {
+ render()
+ expect(screen.getByTestId('legend-soft')).toBeInTheDocument()
+ expect(screen.getByTestId('legend-medium')).toBeInTheDocument()
+ expect(screen.queryByTestId('legend-hard')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('legend-wet')).not.toBeInTheDocument()
+ expect(screen.getByText('Soft')).toBeInTheDocument()
+ expect(screen.getByText('Medium')).toBeInTheDocument()
+ })
+
+ it('renders empty state when rows are empty', () => {
+ render()
+ expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
+ expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
+ })
+})
+
+const results: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671C6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: null,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ {
+ driver_number: 44,
+ position: 2,
+ name_acronym: 'HAM',
+ full_name: 'Lewis Hamilton',
+ team_name: 'Ferrari',
+ team_colour: 'E8002D',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: null,
+ gap_to_leader: 5.1,
+ number_of_laps: 78,
+ points: 18,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+]
+
+const stints: Stint[] = [
+ {
+ session_key: 9472,
+ driver_number: 1,
+ meeting_key: 1229,
+ stint_number: 1,
+ compound: 'MEDIUM',
+ lap_start: 1,
+ lap_end: 30,
+ tyre_age_at_start: 0,
+ },
+ {
+ session_key: 9472,
+ driver_number: 44,
+ meeting_key: 1229,
+ stint_number: 1,
+ compound: 'SOFT',
+ lap_start: 1,
+ lap_end: 18,
+ tyre_age_at_start: 0,
+ },
+]
+
+const pitStops: PitStop[] = []
+
+describe('StrategyView integration', () => {
+ it('renders timeline when stints exist', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument()
+ expect(screen.getByTestId('stint-timeline')).toBeInTheDocument()
+ expect(screen.getByText('VER')).toBeInTheDocument()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(2)
+ })
+})
diff --git a/frontend/src/test/tyredeg.test.ts b/frontend/src/test/tyredeg.test.ts
new file mode 100644
index 0000000..99fb867
--- /dev/null
+++ b/frontend/src/test/tyredeg.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, it } from 'vitest'
+import {
+ DEG_SLOPE_THRESHOLD,
+ MIN_CLEAN_LAPS,
+ PIT_LOSS_SECONDS,
+ cleanStintSamples,
+ degradationModel,
+ estimatePitRejoin,
+ formatSlope,
+ parseLapTimeSeconds,
+ recordStintSamples,
+ stintInputFromRow,
+} from '../lib/tyredeg'
+import type { StintHistoryMap, StintLapInput, StintSample } from '../lib/tyredeg'
+import type { LiveTimingRow } from '../lib/live'
+import type { LiveDriverData } from '../types'
+
+function makeInput(overrides: Partial = {}): StintLapInput {
+ return {
+ racingNumber: '1',
+ lapNumber: 1,
+ lastLapTime: '1:30.000',
+ compound: 'MEDIUM',
+ tyreAge: 1,
+ inPit: false,
+ pitOut: false,
+ ...overrides,
+ }
+}
+
+/** Feed a sequence of snapshots for one driver through the accumulator. */
+function accumulate(snapshots: Array>): StintHistoryMap {
+ let history: StintHistoryMap = {}
+ for (const snapshot of snapshots) {
+ history = recordStintSamples(history, [makeInput(snapshot)])
+ }
+ return history
+}
+
+function makeRow(
+ number: string,
+ position: number,
+ tla: string,
+ driver: Partial = {},
+): LiveTimingRow {
+ return {
+ RacingNumber: number,
+ Position: position,
+ Driver: {
+ RacingNumber: number,
+ Position: position,
+ Interval: '',
+ GapToLeader: '',
+ LastLapTime: '1:30.000',
+ NumberOfLaps: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ ...driver,
+ } as LiveDriverData,
+ Info: {
+ RacingNumber: number,
+ BroadcastName: '',
+ Tla: tla,
+ TeamName: '',
+ TeamColour: '3671c6',
+ FirstName: '',
+ LastName: '',
+ },
+ }
+}
+
+describe('parseLapTimeSeconds', () => {
+ it('parses minute-form and plain-second lap times', () => {
+ expect(parseLapTimeSeconds('1:23.456')).toBeCloseTo(83.456)
+ expect(parseLapTimeSeconds('83.456')).toBeCloseTo(83.456)
+ })
+
+ it('rejects empty, garbage, and non-positive values', () => {
+ expect(parseLapTimeSeconds('')).toBeNull()
+ expect(parseLapTimeSeconds(undefined)).toBeNull()
+ expect(parseLapTimeSeconds(null)).toBeNull()
+ expect(parseLapTimeSeconds('LAP 12')).toBeNull()
+ expect(parseLapTimeSeconds('-1:30.000')).toBeNull()
+ })
+})
+
+describe('recordStintSamples', () => {
+ it('accumulates one sample per completed lap across snapshots', () => {
+ const history = accumulate([
+ { lapNumber: 1, lastLapTime: '1:30.000' },
+ { lapNumber: 2, lastLapTime: '1:30.100' },
+ { lapNumber: 2, lastLapTime: '1:30.100' }, // repeated snapshot, same lap
+ { lapNumber: 3, lastLapTime: '1:30.200' },
+ ])
+ expect(history['1'].samples).toEqual([
+ { lap: 2, seconds: 90.1 },
+ { lap: 3, seconds: 90.2 },
+ ])
+ })
+
+ it('does not record a lap from the very first snapshot seen', () => {
+ const history = accumulate([{ lapNumber: 5, lastLapTime: '1:29.000' }])
+ expect(history['1'].samples).toEqual([])
+ })
+
+ it('replaces (not duplicates) a sample when the lap time is corrected', () => {
+ const history = accumulate([
+ { lapNumber: 1, lastLapTime: '1:30.000' },
+ { lapNumber: 2, lastLapTime: '1:30.500' },
+ { lapNumber: 2, lastLapTime: '1:30.400' }, // late correction for lap 2
+ ])
+ expect(history['1'].samples).toEqual([{ lap: 2, seconds: 90.4 }])
+ })
+
+ it('resets the stint on compound change and skips the next lap', () => {
+ const history = accumulate([
+ { lapNumber: 1, lastLapTime: '1:30.000', compound: 'SOFT', tyreAge: 1 },
+ { lapNumber: 2, lastLapTime: '1:30.100', compound: 'SOFT', tyreAge: 2 },
+ { lapNumber: 3, lastLapTime: '1:30.200', compound: 'SOFT', tyreAge: 3 },
+ // Boxed for hards: old samples dropped, out-lap (lap 4) excluded.
+ { lapNumber: 4, lastLapTime: '1:52.000', compound: 'HARD', tyreAge: 0 },
+ { lapNumber: 5, lastLapTime: '1:31.000', compound: 'HARD', tyreAge: 1 },
+ { lapNumber: 6, lastLapTime: '1:31.100', compound: 'HARD', tyreAge: 2 },
+ ])
+ expect(history['1'].samples).toEqual([
+ { lap: 5, seconds: 91.0 },
+ { lap: 6, seconds: 91.1 },
+ ])
+ })
+
+ it('resets on a fresh set of the same compound (tyre age drops)', () => {
+ const history = accumulate([
+ { lapNumber: 1, lastLapTime: '1:30.000', tyreAge: 10 },
+ { lapNumber: 2, lastLapTime: '1:30.100', tyreAge: 11 },
+ { lapNumber: 3, lastLapTime: '1:50.000', tyreAge: 0 }, // new mediums
+ { lapNumber: 4, lastLapTime: '1:30.500', tyreAge: 1 },
+ ])
+ expect(history['1'].samples).toEqual([{ lap: 4, seconds: 90.5 }])
+ })
+
+ it('excludes laps completed in the pit lane or on pit exit', () => {
+ const history = accumulate([
+ { lapNumber: 1, lastLapTime: '1:30.000' },
+ { lapNumber: 2, lastLapTime: '1:30.100' },
+ { lapNumber: 3, lastLapTime: '1:48.000', inPit: true }, // in-lap
+ { lapNumber: 4, lastLapTime: '1:45.000', pitOut: true, tyreAge: 0 }, // out-lap
+ { lapNumber: 5, lastLapTime: '1:30.300', tyreAge: 1 },
+ ])
+ expect(history['1'].samples).toEqual([{ lap: 5, seconds: 90.3 }])
+ })
+
+ it('prunes drivers missing from the snapshot and does not mutate input', () => {
+ const initial = accumulate([
+ { lapNumber: 1, lastLapTime: '1:30.000' },
+ { lapNumber: 2, lastLapTime: '1:30.100' },
+ ])
+ const next = recordStintSamples(initial, [makeInput({ racingNumber: '44' })])
+ expect(next['1']).toBeUndefined()
+ expect(next['44']).toBeDefined()
+ expect(initial['1'].samples).toHaveLength(1)
+ })
+})
+
+describe('degradationModel', () => {
+ const series = (times: number[], startLap = 1): StintSample[] =>
+ times.map((seconds, index) => ({ lap: startLap + index, seconds }))
+
+ it('fits the slope of a known linear series', () => {
+ const model = degradationModel(series([90.0, 90.1, 90.2, 90.3, 90.4]))
+ expect(model).not.toBeNull()
+ expect(model!.slope).toBeCloseTo(0.1, 5)
+ expect(model!.trend).toBe('degrading')
+ })
+
+ it('classifies improving and stable stints', () => {
+ expect(degradationModel(series([91.0, 90.8, 90.6, 90.4]))!.trend).toBe('improving')
+ expect(degradationModel(series([90.0, 90.01, 90.0, 90.02, 90.01]))!.trend).toBe('stable')
+ // A slope clearly under the threshold stays stable.
+ expect(DEG_SLOPE_THRESHOLD).toBeGreaterThan(0.03)
+ expect(degradationModel(series([90.0, 90.03, 90.06, 90.09, 90.12]))!.trend).toBe('stable')
+ })
+
+ it('excludes outliers more than ~5s off the stint median', () => {
+ // Clean laps follow +0.1s/lap; the 97.5 (traffic/spin) must not skew the fit.
+ const samples = series([90.0, 90.1, 97.5, 90.3, 90.4, 90.5])
+ expect(cleanStintSamples(samples)).toHaveLength(5)
+ const model = degradationModel(samples)
+ expect(model!.samples).toHaveLength(5)
+ expect(model!.slope).toBeCloseTo(0.1, 5)
+ })
+
+ it('returns null with fewer than MIN_CLEAN_LAPS clean laps', () => {
+ expect(degradationModel(series([90.0, 90.1, 90.2]))).toBeNull()
+ // 4 raw laps but one outlier -> only 3 clean -> still warming up
+ expect(degradationModel(series([90.0, 90.1, 90.2, 99.0]))).toBeNull()
+ expect(MIN_CLEAN_LAPS).toBe(4)
+ })
+})
+
+describe('formatSlope', () => {
+ it('renders signed seconds-per-lap', () => {
+ expect(formatSlope(0.083)).toBe('+0.08s/lap')
+ expect(formatSlope(-0.125)).toBe('−0.13s/lap')
+ })
+})
+
+describe('estimatePitRejoin', () => {
+ const ladder = [
+ makeRow('1', 1, 'VER'),
+ makeRow('4', 2, 'NOR', { Interval: '+5.0' }),
+ makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
+ makeRow('14', 4, 'ALO', { Interval: '+15.0' }),
+ makeRow('16', 5, 'LEC', { Interval: '+20.0' }),
+ ]
+
+ it('projects the rejoin slot between the right cars', () => {
+ // VER pits from P1: NOR (5s) and HAM (15s cumulative) get past;
+ // ALO would be 30s back -> stays behind. Rejoin ~P3.
+ const estimate = estimatePitRejoin(ladder, '1')
+ expect(estimate).toEqual({
+ rejoinPosition: 3,
+ positionsLost: 2,
+ aheadCode: 'HAM',
+ behindCode: 'ALO',
+ })
+ })
+
+ it('keeps position when the cars behind are further than the pit loss', () => {
+ const estimate = estimatePitRejoin(ladder, '14')
+ // LEC is 20s behind ALO — inside the 22s window, so he gets past.
+ expect(estimate!.rejoinPosition).toBe(5)
+ expect(estimate!.aheadCode).toBe('LEC')
+ expect(estimate!.behindCode).toBeNull()
+
+ const wide = estimatePitRejoin(
+ [makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: `+${PIT_LOSS_SECONDS + 3}.0` })],
+ '1',
+ )
+ expect(wide!.rejoinPosition).toBe(1)
+ expect(wide!.positionsLost).toBe(0)
+ expect(wide!.behindCode).toBe('NOR')
+ })
+
+ it('stops the ladder walk at lapped cars and skips pitted/retired ones', () => {
+ const estimate = estimatePitRejoin(
+ [
+ makeRow('1', 1, 'VER'),
+ makeRow('4', 2, 'NOR', { Interval: '+5.0', InPit: true }), // mid-stop, excluded
+ makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
+ makeRow('16', 4, 'LEC', { Interval: '1L' }), // lapped — walk ends here
+ makeRow('14', 5, 'ALO', { Interval: '+2.0' }),
+ ],
+ '1',
+ )
+ expect(estimate!.positionsLost).toBe(1) // only HAM gets past
+ expect(estimate!.rejoinPosition).toBe(2)
+ expect(estimate!.aheadCode).toBe('HAM')
+ expect(estimate!.behindCode).toBe('LEC')
+ })
+
+ it('returns null for unknown drivers', () => {
+ expect(estimatePitRejoin(ladder, '99')).toBeNull()
+ })
+})
+
+describe('stintInputFromRow', () => {
+ it('maps tower rows into accumulator inputs', () => {
+ const row = makeRow('4', 2, 'NOR', { NumberOfLaps: 12, LastLapTime: '1:29.900' })
+ row.Tyre = { Compound: 'HARD', New: false, Age: 7 }
+ expect(stintInputFromRow(row)).toEqual({
+ racingNumber: '4',
+ lapNumber: 12,
+ lastLapTime: '1:29.900',
+ compound: 'HARD',
+ tyreAge: 7,
+ inPit: false,
+ pitOut: false,
+ })
+ })
+})
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 68e4929..790c7e8 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -261,6 +261,7 @@ export interface LiveSessionMeta {
CircuitName: string
SessionType: string
SessionName: string
+ Path: string
}
export interface LiveStintData {
@@ -269,6 +270,12 @@ export interface LiveStintData {
Laps: number
}
+export interface LiveRadioCapture {
+ Utc: string
+ RacingNumber: string
+ Path: string
+}
+
export interface LiveStreamData {
Drivers: Record
DriverInfo: Record
@@ -277,6 +284,7 @@ export interface LiveStreamData {
RCMessages: LiveRCMessage[]
Weather: LiveWeatherData
Session: LiveSessionMeta
+ TeamRadio: LiveRadioCapture[]
TrackStatus: string
CurrentLap: number
TotalLaps: number
diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go
index 5667a45..4f60166 100644
--- a/internal/live/parser_test.go
+++ b/internal/live/parser_test.go
@@ -5,6 +5,7 @@ import (
"compress/flate"
"encoding/base64"
"encoding/json"
+ "fmt"
"testing"
"time"
@@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
"Name": "Race",
- "Type": "Race"
+ "Type": "Race",
+ "Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
t.Fatalf("session = %+v", s)
}
+ if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" {
+ t.Fatalf("session path = %q", s.Path)
+ }
}
func TestProcessTopicDriverList(t *testing.T) {
@@ -285,6 +290,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) {
}
}
+func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) {
+ state := live.NewState()
+ snapshot := json.RawMessage(`{
+ "Captures": [
+ {"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"},
+ {"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"}
+ ]
+ }`)
+
+ if !state.ProcessTopic("TeamRadio", snapshot) {
+ t.Fatal("TeamRadio snapshot should update state")
+ }
+
+ patch := json.RawMessage(`{
+ "Captures": {
+ "2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"}
+ }
+ }`)
+ if !state.ProcessTopic("TeamRadio", patch) {
+ t.Fatal("TeamRadio keyed patch should update state")
+ }
+
+ radio := state.Snapshot().TeamRadio
+ if len(radio) != 3 {
+ t.Fatalf("radio captures = %d, want 3", len(radio))
+ }
+ if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" {
+ t.Fatalf("radio order = %+v", radio)
+ }
+}
+
+func TestProcessTopicTeamRadioMalformed(t *testing.T) {
+ state := live.NewState()
+ if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) {
+ t.Fatal("incomplete TeamRadio capture should not update state")
+ }
+ if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) {
+ t.Fatal("unexpected TeamRadio captures shape should not update state")
+ }
+ if len(state.Snapshot().TeamRadio) != 0 {
+ t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio)
+ }
+}
+
+func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) {
+ state := live.NewState()
+ for i := 0; i < 25; i++ {
+ payload := json.RawMessage([]byte(fmt.Sprintf(`{
+ "Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}]
+ }`, i, i, i)))
+ state.ProcessTopic("TeamRadio", payload)
+ }
+
+ radio := state.Snapshot().TeamRadio
+ if len(radio) != 20 {
+ t.Fatalf("radio captures = %d, want 20", len(radio))
+ }
+ if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" {
+ t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19])
+ }
+}
+
func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{
diff --git a/internal/live/signalr.go b/internal/live/signalr.go
index e9b8342..9265d70 100644
--- a/internal/live/signalr.go
+++ b/internal/live/signalr.go
@@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
"RaceControlMessages",
"WeatherData",
"SessionInfo",
+ "TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
@@ -196,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err
}
- subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
+ subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
diff --git a/internal/live/state.go b/internal/live/state.go
index bd7d8d4..08d1488 100644
--- a/internal/live/state.go
+++ b/internal/live/state.go
@@ -8,6 +8,8 @@ import (
"encoding/json"
"fmt"
"io"
+ "log"
+ "sort"
"strings"
"time"
)
@@ -23,6 +25,7 @@ type State struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
+ TeamRadio []LiveRadioCapture
TrackStatus string
CurrentLap int
TotalLaps int
@@ -34,6 +37,7 @@ type State struct {
}
const signalRRecordSeparator = byte(0x1e)
+const maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator.
func NewState() *State {
@@ -71,6 +75,8 @@ func (s *State) Snapshot() LiveStreamData {
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages)
+ cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
+ copy(cpyRadio, s.TeamRadio)
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints {
st := make([]LiveStintData, len(v))
@@ -86,6 +92,7 @@ func (s *State) Snapshot() LiveStreamData {
RCMessages: cpyRC,
Weather: s.Weather,
Session: s.Session,
+ TeamRadio: cpyRadio,
TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps,
@@ -335,6 +342,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
+ Path string `json:"Path"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
@@ -349,8 +357,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if si.Type != "" {
s.Session.SessionType = si.Type
}
+ if si.Path != "" {
+ s.Session.Path = si.Path
+ }
updated = true
}
+ case "TeamRadio":
+ updated = s.updateTeamRadio(data)
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
@@ -483,6 +496,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
return out, err == nil
}
+func (s *State) updateTeamRadio(data json.RawMessage) bool {
+ var payload struct {
+ Captures json.RawMessage `json:"Captures"`
+ }
+ if err := json.Unmarshal(data, &payload); err != nil {
+ log.Printf("live: skipping malformed TeamRadio payload: %v", err)
+ return false
+ }
+ if len(payload.Captures) == 0 || string(payload.Captures) == "null" {
+ log.Printf("live: skipping TeamRadio payload without Captures")
+ return false
+ }
+
+ captures := indexedRawValues(payload.Captures)
+ if len(captures) == 0 {
+ log.Printf("live: skipping TeamRadio payload with unexpected Captures shape")
+ return false
+ }
+
+ updated := false
+ for _, captureRaw := range captures {
+ var capture LiveRadioCapture
+ if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil {
+ log.Printf("live: skipping malformed TeamRadio capture: %v", err)
+ continue
+ }
+ if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" {
+ log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path)
+ continue
+ }
+ if s.hasTeamRadioCapture(capture) {
+ continue
+ }
+ s.TeamRadio = append(s.TeamRadio, capture)
+ updated = true
+ }
+ if updated {
+ sort.SliceStable(s.TeamRadio, func(i, j int) bool {
+ return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc
+ })
+ if len(s.TeamRadio) > maxTeamRadioCaptures {
+ s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...)
+ }
+ }
+ return updated
+}
+
+func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool {
+ for _, existing := range s.TeamRadio {
+ if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path {
+ return true
+ }
+ }
+ return false
+}
+
func (s *State) updatePositions(data json.RawMessage) bool {
var payload struct {
Position json.RawMessage `json:"Position"`
@@ -799,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
fmt.Sscanf(k, "%d", &i)
values = append(values, indexedRaw{Index: i, Raw: v})
}
+ sort.Slice(values, func(i, j int) bool {
+ return values[i].Index < values[j].Index
+ })
return values
}
diff --git a/internal/live/types.go b/internal/live/types.go
index 72e047c..4d62f6d 100644
--- a/internal/live/types.go
+++ b/internal/live/types.go
@@ -85,6 +85,7 @@ type LiveSessionMeta struct {
CircuitName string
SessionType string
SessionName string
+ Path string
}
// LivePositionData is the latest raw F1 GPS position for one driver.
@@ -144,6 +145,13 @@ type LiveStintData struct {
Laps int
}
+// LiveRadioCapture is one team radio audio clip from the live timing feed.
+type LiveRadioCapture struct {
+ Utc string
+ RacingNumber string
+ Path string
+}
+
// LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct {
Drivers map[string]LiveDriverData
@@ -153,6 +161,7 @@ type LiveStreamData struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
+ TeamRadio []LiveRadioCapture
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts
index a744e23..a63e327 100644
--- a/tests/command-center.spec.ts
+++ b/tests/command-center.spec.ts
@@ -9,7 +9,7 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
- await expect(page.getByTestId('cc-actions')).toBeVisible()
+ await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
})
test('nav link reaches command center from race hub', async ({ page }) => {
@@ -21,8 +21,8 @@ test.describe('Command Center', () => {
test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/')
- await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
- await page.getByTestId('cc-action-race-hub').click()
+ await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
+ await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()