From 24bcac8038332a5461336d3de9f0f407036a22fd Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 4 Jul 2026 00:24:02 -0400 Subject: [PATCH] feat(frontend): annotate key numbers with meaning (#18) Add shared Meaning primitive and pure interpretation helpers for interval, tyre age, and championship gap columns on the live tower, tyre deg panel, and championship hub. Thresholds are exported consts (undercut window tied to PIT_LOSS_SECONDS from #13). Co-authored-by: Cursor --- frontend/src/components/Meaning.tsx | 26 ++++ frontend/src/components/live/TimingTower.tsx | 18 ++- frontend/src/components/live/TyreDegPanel.tsx | 12 +- frontend/src/lib/meaning.ts | 146 ++++++++++++++++++ frontend/src/pages/ChampionshipPage.tsx | 29 +++- frontend/src/styles/meaning.css | 48 ++++++ frontend/src/test/ChampionshipPage.test.tsx | 1 + frontend/src/test/LiveComponents.test.tsx | 20 +++ frontend/src/test/Meaning.test.tsx | 26 ++++ frontend/src/test/TyreDegPanel.test.tsx | 9 ++ frontend/src/test/meaning.test.ts | 71 +++++++++ 11 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/Meaning.tsx create mode 100644 frontend/src/lib/meaning.ts create mode 100644 frontend/src/styles/meaning.css create mode 100644 frontend/src/test/Meaning.test.tsx create mode 100644 frontend/src/test/meaning.test.ts diff --git a/frontend/src/components/Meaning.tsx b/frontend/src/components/Meaning.tsx new file mode 100644 index 0000000..9c5d470 --- /dev/null +++ b/frontend/src/components/Meaning.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import '../styles/meaning.css' + +export interface MeaningProps { + value: ReactNode + meaning?: string | null + /** Long-form explanation for the native tooltip; falls back to meaning. */ + title?: string | null + tone?: 'good' | 'bad' | 'neutral' | 'warn' +} + +export function Meaning({ value, meaning, title, tone }: MeaningProps) { + if (!meaning) { + return <>{value} + } + + const tooltip = title ?? meaning + const toneClass = tone ? `meaning-caption--${tone}` : '' + + return ( + + {value} + {meaning} + + ) +} diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx index b20810b..0b38dcd 100644 --- a/frontend/src/components/live/TimingTower.tsx +++ b/frontend/src/components/live/TimingTower.tsx @@ -12,6 +12,9 @@ import { tyreLabel, } from '../../lib/live' import type { GapHistoryMap } from '../../lib/gapHistory' +import { parseIntervalSeconds } from '../../lib/gapHistory' +import { intervalMeaning } from '../../lib/meaning' +import { Meaning } from '../Meaning' import { GapSparkline } from './GapSparkline' import { StintHistory } from './StintHistory' import { Pin } from 'lucide-react' @@ -100,7 +103,11 @@ export function TimingTower({ const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader - + const intervalAnnotation = + gapMode === 'interval' && isRace && row.Position > 1 + ? intervalMeaning(parseIntervalSeconds(gapText)) + : null + const renderSector = (idx: number) => { const sec = driver.Sectors?.[idx] if (!sec) return '-' @@ -149,7 +156,14 @@ export function TimingTower({ {driver.LastLapTime || '-'} - {gapText || '-'} + + + {isRace && ( diff --git a/frontend/src/components/live/TyreDegPanel.tsx b/frontend/src/components/live/TyreDegPanel.tsx index d7f072c..3c15f74 100644 --- a/frontend/src/components/live/TyreDegPanel.tsx +++ b/frontend/src/components/live/TyreDegPanel.tsx @@ -13,6 +13,8 @@ import { recordStintSamples, stintInputFromRow, } from '../../lib/tyredeg' +import { tyreAgeMeaning } from '../../lib/meaning' +import { Meaning } from '../Meaning' import '../../styles/tyredeg.css' const TOP_DRIVER_COUNT = 10 @@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) { {visible.map((row) => { const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null + const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age) return (
P{row.Position} {driverCode(row)} - {tyreLabel(row.Tyre)} + + + {model ? ( <> diff --git a/frontend/src/lib/meaning.ts b/frontend/src/lib/meaning.ts new file mode 100644 index 0000000..25db0a9 --- /dev/null +++ b/frontend/src/lib/meaning.ts @@ -0,0 +1,146 @@ +// Pure interpretation helpers for pairing numbers with their "so-what". +// Thresholds are exported consts so they are cheap to tune in one place. + +import { PIT_LOSS_SECONDS } from './tyredeg' + +/** Gaps under this (seconds) are DRS attack range. */ +export const INTERVAL_DRS_MAX_SECONDS = 1.0 + +/** Lower bound of the undercut window (seconds); contiguous with DRS range. */ +export const INTERVAL_UNDERCUT_MIN_SECONDS = INTERVAL_DRS_MAX_SECONDS + +/** + * Upper bound of the undercut window (seconds). Kept well below typical pit + * loss ({@link PIT_LOSS_SECONDS}s) — only a few seconds matter for strategy. + */ +export const INTERVAL_UNDERCUT_MAX_SECONDS = Math.min(3.0, PIT_LOSS_SECONDS / 7) + +/** + * Rough per-compound cliff lap estimates (dry compounds). Wet/intermediate + * values are conservative — deg varies wildly with conditions. + */ +export const TYRE_CLIFF_LAPS: Readonly> = { + SOFT: 18, + MEDIUM: 28, + HARD: 38, + INTERMEDIATE: 20, + WET: 15, +} + +/** Default cliff when compound is unknown. */ +export const TYRE_CLIFF_DEFAULT_LAPS = 25 + +/** Championship max points per race (winner). */ +export const MAX_POINTS_PER_ROUND = 25 + +export interface MeaningAnnotation { + caption: string + title: string + tone?: 'good' | 'bad' | 'neutral' | 'warn' +} + +function cliffLaps(compound: string | null | undefined): number { + if (!compound) return TYRE_CLIFF_DEFAULT_LAPS + return TYRE_CLIFF_LAPS[compound.toUpperCase()] ?? TYRE_CLIFF_DEFAULT_LAPS +} + +/** + * Interval / gap-to-ahead meaning for the live timing tower. + * Returns null for leader gaps, out-of-range values, or unparsable input. + */ +export function intervalMeaning(seconds: number | null | undefined): MeaningAnnotation | null { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null + + if (seconds < INTERVAL_DRS_MAX_SECONDS) { + return { + caption: 'DRS range', + title: `Within ${INTERVAL_DRS_MAX_SECONDS}s — DRS enabled next straight`, + tone: 'good', + } + } + + if (seconds >= INTERVAL_UNDERCUT_MIN_SECONDS && seconds <= INTERVAL_UNDERCUT_MAX_SECONDS) { + return { + caption: 'undercut window', + title: `${INTERVAL_UNDERCUT_MIN_SECONDS}–${INTERVAL_UNDERCUT_MAX_SECONDS}s — pit now could gain a position (vs ~${PIT_LOSS_SECONDS}s stop)`, + tone: 'warn', + } + } + + return null +} + +/** + * Tyre-age meaning for deg / stint panels. + */ +export function tyreAgeMeaning( + compound: string | null | undefined, + age: number | null | undefined, +): MeaningAnnotation | null { + if (age == null || !Number.isFinite(age) || age < 0) return null + + const cliff = cliffLaps(compound) + const freshEnd = Math.ceil(cliff * 0.25) + const midEnd = Math.ceil(cliff * 0.65) + + if (age <= freshEnd) { + return { + caption: 'fresh', + title: `${age} lap${age === 1 ? '' : 's'} on ${compound ?? 'tyre'} — early stint grip`, + tone: 'good', + } + } + + if (age <= midEnd) { + return { + caption: 'mid-life', + title: `${age} laps — tyre in its working window before cliff (~${cliff} laps)`, + tone: 'neutral', + } + } + + const lapsToCliff = cliff - age + if (lapsToCliff <= 0) { + return { + caption: 'past cliff', + title: `${age} laps — beyond typical ${compound ?? 'tyre'} cliff (~${cliff} laps)`, + tone: 'bad', + } + } + + return { + caption: `~${lapsToCliff} laps to cliff`, + title: `${age} of ~${cliff} laps before deg cliff on ${compound ?? 'tyre'}`, + tone: 'warn', + } +} + +/** + * Points gap to the driver directly ahead — catchable-or-not v1. + */ +export function pointsGapMeaning( + gapToAhead: number | null | undefined, + roundsLeft: number, + driverAhead?: string | null, +): MeaningAnnotation | null { + if (gapToAhead == null || !Number.isFinite(gapToAhead) || gapToAhead <= 0) return null + if (!Number.isFinite(roundsLeft) || roundsLeft <= 0) return null + + const maxCatchable = roundsLeft * MAX_POINTS_PER_ROUND + const ahead = driverAhead?.trim() || 'ahead' + + if (gapToAhead > maxCatchable) { + return { + caption: 'out of reach', + title: `+${gapToAhead} pts with ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left (max ${maxCatchable} available)`, + tone: 'bad', + } + } + + const perRound = Math.ceil(gapToAhead / roundsLeft) + return { + caption: `~${perRound} pts/round`, + title: `Needs ~${perRound} pts per round on ${ahead} to catch (${gapToAhead} pts in ${roundsLeft} round${roundsLeft === 1 ? '' : 's'})`, + tone: perRound <= 10 ? 'good' : 'warn', + } +} diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index d1c293e..c004eb1 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -4,6 +4,8 @@ import { fetchChampionshipHub, fetchSeasons } from '../api' import { teamColor } from '../utils' import type { ChampHubDriver, ChampionshipHub } from '../types' import { ChampionshipSimulator } from '../components/ChampionshipSimulator' +import { Meaning } from '../components/Meaning' +import { pointsGapMeaning } from '../lib/meaning' type View = 'drivers' | 'constructors' | 'progression' | 'simulator' @@ -138,6 +140,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { color: teamColor(d.team_colour), gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`, gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`, + gapAheadNum, + driverAhead: i === 0 ? null : drivers[i - 1].name_acronym, spark: sparkPoints(d.form), h2h: `${d.teammate_wins}–${d.teammate_losses}`, h2hWin: d.teammate_wins >= d.teammate_losses, @@ -233,7 +237,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
{view === 'drivers' && ( - + )} {view === 'constructors' && } {view === 'progression' && } @@ -248,6 +257,8 @@ interface EnrichedDriver { color: string gapLeader: string gapAhead: string + gapAheadNum: number | null + driverAhead: string | null spark: string h2h: string h2hWin: boolean @@ -259,10 +270,12 @@ function DriversView({ enriched, leaderPoints, titleMath, + roundsLeft, }: { enriched: EnrichedDriver[] leaderPoints: number titleMath: string + roundsLeft: number }) { const podium = enriched.slice(0, 3) return ( @@ -357,7 +370,19 @@ function DriversView({ {e.d.team_name} {fmtPts(e.d.points)} {e.gapLeader} - {e.gapAhead} + + {(() => { + const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead) + return ( + + ) + })()} + 0 ? 'var(--text)' : 'var(--text-3)' }}> {e.d.wins} diff --git a/frontend/src/styles/meaning.css b/frontend/src/styles/meaning.css new file mode 100644 index 0000000..3c245bf --- /dev/null +++ b/frontend/src/styles/meaning.css @@ -0,0 +1,48 @@ +/* Compact value + muted meaning caption (issue #18). */ + +.meaning { + display: inline-flex; + flex-direction: column; + align-items: inherit; + gap: 1px; + line-height: 1.2; +} + +.meaning-value { + /* inherits table cell mono styling from parent */ +} + +.meaning-caption { + font-size: 10px; + font-family: var(--f-mono); + color: var(--text-3); + letter-spacing: 0.02em; + white-space: nowrap; +} + +.meaning-caption--good { + color: var(--green); +} + +.meaning-caption--bad { + color: var(--red); +} + +.meaning-caption--warn { + color: var(--yellow, #e8c547); +} + +.meaning-caption--neutral { + color: var(--text-3); +} + +/* Table cells: right-align caption under numeric values */ +td.r .meaning, +.champ-td-dim .meaning, +.champ-td-muted .meaning { + align-items: flex-end; +} + +.tyredeg-row .meaning { + align-items: flex-start; +} diff --git a/frontend/src/test/ChampionshipPage.test.tsx b/frontend/src/test/ChampionshipPage.test.tsx index 75b2833..35c5774 100644 --- a/frontend/src/test/ChampionshipPage.test.tsx +++ b/frontend/src/test/ChampionshipPage.test.tsx @@ -112,6 +112,7 @@ describe('ChampionshipPage', () => { expect(screen.getAllByText('VER').length).toBeGreaterThan(0) expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument() expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title') + expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0) }) it('switches to constructors and progression views', async () => { diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx index 7af18bc..95ea7ac 100644 --- a/frontend/src/test/LiveComponents.test.tsx +++ b/frontend/src/test/LiveComponents.test.tsx @@ -189,6 +189,26 @@ describe('TimingTower', () => { expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument() }) + it('annotates DRS-range intervals in race mode', () => { + const raceRows = [ + makeRow('1', 1, 'VER'), + makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }), + ] + render( + , + ) + expect(screen.getByText('DRS range')).toBeInTheDocument() + }) + it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => { const sprintRows = Array.from({ length: 22 }, (_, index) => makeRow(String(index + 1), index + 1, `D${index + 1}`), diff --git a/frontend/src/test/Meaning.test.tsx b/frontend/src/test/Meaning.test.tsx new file mode 100644 index 0000000..94b0e58 --- /dev/null +++ b/frontend/src/test/Meaning.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Meaning } from '../components/Meaning' + +describe('Meaning', () => { + it('renders bare value when meaning is null', () => { + render() + expect(screen.getByText('+1.234')).toBeInTheDocument() + expect(screen.queryByText('DRS range')).not.toBeInTheDocument() + }) + + it('renders value with caption and tooltip title', () => { + render( + , + ) + const value = screen.getByText('+0.4') + expect(value).toBeInTheDocument() + expect(screen.getByText('DRS range')).toHaveClass('meaning-caption--good') + expect(value.closest('.meaning')).toHaveAttribute('title', 'Within 1.0s — DRS enabled next straight') + }) +}) diff --git a/frontend/src/test/TyreDegPanel.test.tsx b/frontend/src/test/TyreDegPanel.test.tsx index 1bb9eff..40975a3 100644 --- a/frontend/src/test/TyreDegPanel.test.tsx +++ b/frontend/src/test/TyreDegPanel.test.tsx @@ -52,9 +52,18 @@ describe('TyreDegPanel', () => { const panel = screen.getByTestId('tyredeg-panel') expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('M +5') + expect(panel).toHaveTextContent('fresh') expect(panel).toHaveTextContent('warming up') }) + it('annotates tyre age meaning on stint rows', () => { + const rows = [ + makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }), + ] + render() + expect(screen.getByText('mid-life')).toBeInTheDocument() + }) + it('renders slope and rejoin estimate once laps accumulate across snapshots', () => { const { rerender } = render( , diff --git a/frontend/src/test/meaning.test.ts b/frontend/src/test/meaning.test.ts new file mode 100644 index 0000000..8d7c6e6 --- /dev/null +++ b/frontend/src/test/meaning.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + INTERVAL_DRS_MAX_SECONDS, + INTERVAL_UNDERCUT_MAX_SECONDS, + INTERVAL_UNDERCUT_MIN_SECONDS, + MAX_POINTS_PER_ROUND, + TYRE_CLIFF_LAPS, + intervalMeaning, + pointsGapMeaning, + tyreAgeMeaning, +} from '../lib/meaning' + +describe('intervalMeaning', () => { + it('returns DRS range below the threshold', () => { + expect(intervalMeaning(0.4)?.caption).toBe('DRS range') + expect(intervalMeaning(INTERVAL_DRS_MAX_SECONDS - 0.01)?.caption).toBe('DRS range') + }) + + it('returns undercut window in the middle band', () => { + expect(intervalMeaning(INTERVAL_UNDERCUT_MIN_SECONDS)?.caption).toBe('undercut window') + expect(intervalMeaning(2.0)?.caption).toBe('undercut window') + expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS)?.caption).toBe('undercut window') + }) + + it('returns null outside known bands', () => { + expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS + 0.5)).toBeNull() + expect(intervalMeaning(10)).toBeNull() + expect(intervalMeaning(null)).toBeNull() + expect(intervalMeaning(undefined)).toBeNull() + expect(intervalMeaning(-1)).toBeNull() + }) +}) + +describe('tyreAgeMeaning', () => { + it('labels fresh, mid-life, and laps-to-cliff for SOFT', () => { + const cliff = TYRE_CLIFF_LAPS.SOFT + expect(tyreAgeMeaning('SOFT', 2)?.caption).toBe('fresh') + expect(tyreAgeMeaning('SOFT', Math.ceil(cliff * 0.5))?.caption).toBe('mid-life') + expect(tyreAgeMeaning('SOFT', cliff - 2)?.caption).toBe('~2 laps to cliff') + expect(tyreAgeMeaning('SOFT', cliff + 5)?.caption).toBe('past cliff') + }) + + it('handles unknown compounds with defaults', () => { + expect(tyreAgeMeaning('UNKNOWN', 3)?.caption).toBe('fresh') + expect(tyreAgeMeaning(undefined, 3)?.caption).toBe('fresh') + }) + + it('returns null for invalid age', () => { + expect(tyreAgeMeaning('MEDIUM', null)).toBeNull() + expect(tyreAgeMeaning('MEDIUM', -1)).toBeNull() + }) +}) + +describe('pointsGapMeaning', () => { + it('computes catchable pts/round', () => { + const result = pointsGapMeaning(40, 4, 'VER') + expect(result?.caption).toBe('~10 pts/round') + expect(result?.title).toContain('VER') + }) + + it('marks uncatchable gaps', () => { + const max = 3 * MAX_POINTS_PER_ROUND + expect(pointsGapMeaning(max + 1, 3, 'VER')?.caption).toBe('out of reach') + }) + + it('returns null for leader or invalid input', () => { + expect(pointsGapMeaning(0, 4)).toBeNull() + expect(pointsGapMeaning(10, 0)).toBeNull() + expect(pointsGapMeaning(null, 4)).toBeNull() + }) +})