diff --git a/frontend/src/components/live/TyreDegPanel.tsx b/frontend/src/components/live/TyreDegPanel.tsx index 77e8219..0f36737 100644 --- a/frontend/src/components/live/TyreDegPanel.tsx +++ b/frontend/src/components/live/TyreDegPanel.tsx @@ -53,9 +53,8 @@ function StintSparkline({ seconds }: { seconds: number[] }) { export function TyreDegPanel({ rows, sessionType, pinned }: Props) { const isRace = isRaceSession(sessionType) - // In practice/qualifying deg trends are secondary — collapse by default so - // the Timing Tower stays above the fold. Races keep it open. - const [collapsed, setCollapsed] = useState(!isRace) + const [collapsed, setCollapsed] = useState(true) + const [readerChose, setReaderChose] = useState(false) const [stints, setStints] = useState({}) // One lap-history update per received snapshot (rows is rebuilt per snapshot). @@ -75,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) { [rows, pinned], ) + // One linear fit per driver per snapshot, shared by the readiness check and + // the rows below. degradationModel is O(laps) and this runs at feed rate. + const models = useMemo(() => { + const out: Record> = {} + for (const row of visible) { + out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? []) + } + return out + }, [visible, stints]) + + // Before any stint has enough clean laps to fit, every row reads "warming + // up" — a full-height panel of placeholders that pushed the Timing Tower off + // the fold for the first third of a race. Stay collapsed until there is + // something to say, then open. A reader who has toggled it keeps their choice. + const hasSignal = useMemo( + () => visible.some((row) => models[row.RacingNumber] != null), + [visible, models], + ) + + useEffect(() => { + if (readerChose) return + setCollapsed(!(isRace && hasSignal)) + }, [isRace, hasSignal, readerChose]) + if (visible.length === 0) return null return ( @@ -82,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) { {!collapsed && (
{visible.map((row) => { - const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) + const model = models[row.RacingNumber] const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age) return ( diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 098cfe7..b01b320 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -289,14 +289,16 @@ export function LiveTimingPage() { session={snapshot.Session} />
+ {/* Rail runs most-synthesized to most-raw: a reader arriving + mid-session wants "what did I miss" before the regulatory log. */}
+ -
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index b1900f9..6889e55 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; } } .app-nav::-webkit-scrollbar { display: none; } +/* The nav scrolls horizontally with its scrollbar hidden. Below the width + where the links stop fitting, fade the trailing edge so the cut-off item + reads as "scroll for more" instead of as a clipping bug. */ +@media (max-width: 560px) { + .app-nav { + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%); + mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%); + } +} + .nav-logo { font-family: var(--f-mono); font-size: 15px; diff --git a/frontend/src/test/TyreDegPanel.test.tsx b/frontend/src/test/TyreDegPanel.test.tsx index 8ee8a6e..d7e0baf 100644 --- a/frontend/src/test/TyreDegPanel.test.tsx +++ b/frontend/src/test/TyreDegPanel.test.tsx @@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] { } describe('TyreDegPanel', () => { - it('shows a warming-up placeholder until enough clean laps accumulate', () => { + it('stays collapsed while every stint is still warming up', () => { render() const panel = screen.getByTestId('tyredeg-panel') + + // A panel of "warming up" placeholders carries no information and used to + // push the Timing Tower off the fold for the first third of a race. + expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) + expect(panel).toHaveTextContent('collecting clean laps') + }) + + it('shows a warming-up placeholder on each row once expanded', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /tyre deg/i })) + const panel = screen.getByTestId('tyredeg-panel') expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('M +5') expect(panel).toHaveTextContent('fresh') @@ -61,6 +72,7 @@ describe('TyreDegPanel', () => { makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }), ] render() + fireEvent.click(screen.getByRole('button', { name: /tyre deg/i })) expect(screen.getByText('mid-life')).toBeInTheDocument() }) @@ -92,16 +104,44 @@ describe('TyreDegPanel', () => { expect(panel).not.toHaveTextContent('~P') }) - it('starts expanded during a race', () => { - render() + it('opens itself during a race as soon as a stint has signal', () => { + const { rerender } = render( + , + ) + expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) + + for (let lap = 2; lap <= 6; lap++) { + const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}` + rerender() + } + expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2) }) + it('keeps the reader\'s own collapse choice when signal arrives', () => { + const { rerender } = render( + , + ) + // Reader opens it early, then closes it again — that decision must stick + // even once the panel would otherwise auto-open. + const toggle = screen.getByRole('button', { name: /tyre deg/i }) + fireEvent.click(toggle) + fireEvent.click(toggle) + + for (let lap = 2; lap <= 6; lap++) { + const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}` + rerender() + } + + 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() + fireEvent.click(screen.getByRole('button', { name: /tyre deg/i })) expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11) expect(screen.getByText('D14')).toBeInTheDocument() expect(screen.queryByText('D12')).not.toBeInTheDocument()