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/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/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,
+ })
+ })
+})