Compare commits

...

8 Commits

Author SHA1 Message Date
AmanTahiliani
8fd358748d fix(agents): disable agy implementation dispatch
Codex handoff could not write .agents in its sandbox, so the orchestrator applied the intended fail-fast change: agy remains available for dry-run prompt inspection but non-dry-run dispatch exits before worktree or PR side effects.
2026-07-04 01:11:03 -04:00
Aman Tahiliani
cc4337be88 Merge pull request #43 from AmanTahiliani/phase-1
Phase 1 delivery
2026-07-04 00:37:54 -04:00
AmanTahiliani
addfd6d24d fix(compare): reset driver pair on session change 2026-07-04 00:37:12 -04:00
AmanTahiliani
b5d070e116 docs: add phase 1 PR screenshots 2026-07-04 00:32:20 -04:00
AmanTahiliani
233eefaf12 test: update phase 1 visual snapshots 2026-07-04 00:28:09 -04:00
Aman Tahiliani
51b0238b09 Merge pull request #42 from AmanTahiliani/feat/issue-18-annotate-every-number-with-meaning-ux-p
"Annotate every number with meaning" UX pass (#18)
2026-07-04 00:25:39 -04:00
AmanTahiliani
24bcac8038 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 <cursoragent@cursor.com>
2026-07-04 00:24:02 -04:00
Aman Tahiliani
93ac0ccebb Merge pull request #41 from AmanTahiliani/feat/issue-10-telemetry-compare-overlay-speed-throttle
Telemetry compare overlay (speed/throttle/brake + delta) (#10)
2026-07-04 00:20:52 -04:00
26 changed files with 614 additions and 27 deletions

View File

@@ -24,7 +24,7 @@ bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a - **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`. Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
You review and flip to `Ready`. You review and flip to `Ready`.
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>` - **Implement** (recommended harnesses: `codex` or `cursor`): `.agents/bin/dev implement <issue#> --harness <name>`
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness (or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
headless on the spec → build gate → opens a PR → sets Stage `In Review`. headless on the spec → build gate → opens a PR → sets Stage `In Review`.
- **Review + merge**: use the `review` skill from a harness different from the - **Review + merge**: use the `review` skill from a harness different from the
@@ -42,8 +42,17 @@ local adapter state.
## Adding / fixing a harness ## Adding / fixing a harness
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are tool non-interactively in `<workdir>` on the prompt. For current implementation
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting. dispatch, prefer `codex` or `cursor`; `claude` and `opencode` remain available, and
`pi` still needs flag verification before trusting.
`agy` / Antigravity is deliberately disabled for non-dry-run dispatch as of
2026-07-04. Phase 1 testing found the headless path unreliable: with
`--new-project` it ignored the prompt and tried to scaffold, while without it the CLI
could resume a stale conversation and hang past the print timeout. Keep using
`.agents/bin/dev implement <issue#> --harness agy --dry-run` for prompt inspection
only; real dispatch should use `codex` or `cursor` until a fresh Antigravity
headless invocation is verified and documented.
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
nothing (no worktree, PR, or state change). nothing (no worktree, PR, or state change).

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell. # .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
# #
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>] # dev implement <issue#> --harness <codex|cursor|claude|opencode|pi|agy> [--dry-run] [--base <branch>]
# #
# Grooming is driven interactively via the Claude Code /groom skill; this CLI covers # Grooming is driven interactively via the Claude Code /groom skill; this CLI covers
# the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR). # the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR).
@@ -15,7 +15,7 @@ box-box dev CLI
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>] dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh) harnesses: codex, cursor (recommended) · claude, opencode (available) · agy (dry-run only, disabled for dispatch) · pi (verify flags)
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change) --dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
--base base branch for the worktree/PR (default: main) --base base branch for the worktree/PR (default: main)
EOF EOF

View File

@@ -11,6 +11,18 @@
# ---- MUST-HAVE ---- # ---- MUST-HAVE ----
harness_disabled_reason() { # <name> -> reason on stdout; 0 means disabled
case "$1" in
agy)
cat <<'EOF'
agy is disabled for implementation dispatch as of 2026-07-04: Antigravity headless mode was observed to ignore prompts with --new-project, resume stale conversations without it, and hang past print timeouts. Use codex or cursor until a fresh headless invocation is verified.
EOF
return 0
;;
*) return 1 ;;
esac
}
harness_claude() { # Claude Code — print mode, auto-accept edits harness_claude() { # Claude Code — print mode, auto-accept edits
local dir="$1" prompt="$2" local dir="$1" prompt="$2"
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits ) ( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
@@ -31,14 +43,11 @@ harness_cursor() { # Cursor CLI agent — composer-2.5, hea
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust ) ( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
} }
harness_agy() { # Antigravity CLI — UNRELIABLE headless (2026-07): with harness_agy() { # Antigravity CLI — disabled until headless is verified
# --new-project it ignores the prompt and asks to scaffold a project; without it, it local reason
# resumes the previous conversation (silently keeping its old model — --model only reason="$(harness_disabled_reason agy)"
# applies to new conversations) and can hang past the print timeout. Do not trust for echo "harness_agy: $reason" >&2
# dispatch until fixed upstream; verify with a trivial prompt first. return 2
local dir="$1" prompt="$2"
( cd "$dir" && agy --print --print-timeout 60m \
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
} }
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ---- # ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----

View File

@@ -52,6 +52,15 @@ dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
if ! declare -f "harness_$harness" >/dev/null 2>&1; then if ! declare -f "harness_$harness" >/dev/null 2>&1; then
echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2 echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2
fi fi
if [ "$dry" != 1 ] && declare -f harness_disabled_reason >/dev/null 2>&1; then
local disabled_reason
if disabled_reason="$(harness_disabled_reason "$harness")"; then
echo "harness '$harness' is disabled for non-dry-run dispatch." >&2
echo " $disabled_reason" >&2
echo " Use --dry-run for prompt inspection, or dispatch with --harness codex/cursor." >&2
return 2
fi
fi
local repo_root title body slug branch wt prompt local repo_root title body slug branch wt prompt
repo_root="$(git rev-parse --show-toplevel)" || return 1 repo_root="$(git rev-parse --show-toplevel)" || return 1

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
# shellcheck source=/dev/null
source "$ROOT/.agents/lib/dispatch.sh"
issue_title() { echo "Disable agy harness smoke"; }
issue_body() { echo "## Spec"; echo; echo "Smoke prompt body"; }
get_field() { echo "Ready"; }
set_stage() { echo "unexpected set_stage $*" >&2; return 99; }
run_gate() { echo "unexpected run_gate $*" >&2; return 99; }
unexpected_git_file="$(mktemp "${TMPDIR:-/tmp}/boxbox-agy-git.XXXX")"
rm -f "$unexpected_git_file"
git() {
if [ "${1:-}" = "rev-parse" ]; then
command git "$@"
return
fi
echo "unexpected git $*" >&2
touch "$unexpected_git_file"
return 99
}
set +e
non_dry_output="$(dispatch 47 agy 2>&1)"
non_dry_status=$?
set -e
[ "$non_dry_status" -eq 2 ] || {
echo "expected agy non-dry-run to exit 2, got $non_dry_status" >&2
echo "$non_dry_output" >&2
exit 1
}
[[ "$non_dry_output" == *"harness 'agy' is disabled"* ]] || {
echo "expected disabled-harness message" >&2
echo "$non_dry_output" >&2
exit 1
}
[ ! -e "$unexpected_git_file" ] || {
echo "agy non-dry-run reached git before failing" >&2
echo "$non_dry_output" >&2
exit 1
}
dry_output="$(dispatch 47 agy --dry-run 2>&1)"
[[ "$dry_output" == *"[dry-run] no worktree / harness / PR / state change"* ]] || {
echo "expected agy dry-run to render dispatch preview" >&2
echo "$dry_output" >&2
exit 1
}
[[ "$dry_output" == *"Smoke prompt body"* ]] || {
echo "expected agy dry-run prompt body" >&2
echo "$dry_output" >&2
exit 1
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchLapsComparison, fetchTelemetry } from '../api' import { fetchLapsComparison, fetchTelemetry } from '../api'
import { import {
@@ -57,16 +57,23 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
[results, drivers], [results, drivers],
) )
const previousSessionKey = useRef(sessionKey)
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null) const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null) const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
useEffect(() => { useEffect(() => {
if (previousSessionKey.current !== sessionKey) {
previousSessionKey.current = sessionKey
setDriverA(initialPair?.[0] ?? null)
setDriverB(initialPair?.[1] ?? null)
return
}
if (driverA != null && driverB != null) return if (driverA != null && driverB != null) return
const pair = defaultCompareDriverNumbers(results, drivers) if (!initialPair) return
if (!pair) return setDriverA(initialPair[0])
setDriverA(pair[0]) setDriverB(initialPair[1])
setDriverB(pair[1]) }, [sessionKey, initialPair, driverA, driverB])
}, [results, drivers, driverA, driverB])
const pair = useMemo((): [number, number] | null => { const pair = useMemo((): [number, number] | null => {
if (driverA == null || driverB == null || driverA === driverB) return null if (driverA == null || driverB == null || driverA === driverB) return null

View File

@@ -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 (
<span className="meaning" title={tooltip}>
<span className="meaning-value">{value}</span>
<span className={`meaning-caption ${toneClass}`.trim()}>{meaning}</span>
</span>
)
}

View File

@@ -12,6 +12,9 @@ import {
tyreLabel, tyreLabel,
} from '../../lib/live' } from '../../lib/live'
import type { GapHistoryMap } from '../../lib/gapHistory' 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 { GapSparkline } from './GapSparkline'
import { StintHistory } from './StintHistory' import { StintHistory } from './StintHistory'
import { Pin } from 'lucide-react' import { Pin } from 'lucide-react'
@@ -100,7 +103,11 @@ export function TimingTower({
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader 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 renderSector = (idx: number) => {
const sec = driver.Sectors?.[idx] const sec = driver.Sectors?.[idx]
if (!sec) return '-' if (!sec) return '-'
@@ -149,7 +156,14 @@ export function TimingTower({
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}> <td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'} {driver.LastLapTime || '-'}
</td> </td>
<td className="mono">{gapText || '-'}</td> <td className="mono">
<Meaning
value={gapText || '-'}
meaning={intervalAnnotation?.caption}
title={intervalAnnotation?.title}
tone={intervalAnnotation?.tone}
/>
</td>
{isRace && ( {isRace && (
<td className="spark-cell"> <td className="spark-cell">

View File

@@ -13,6 +13,8 @@ import {
recordStintSamples, recordStintSamples,
stintInputFromRow, stintInputFromRow,
} from '../../lib/tyredeg' } from '../../lib/tyredeg'
import { tyreAgeMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import '../../styles/tyredeg.css' import '../../styles/tyredeg.css'
const TOP_DRIVER_COUNT = 10 const TOP_DRIVER_COUNT = 10
@@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
{visible.map((row) => { {visible.map((row) => {
const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
return ( return (
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row"> <div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
<span className="tyredeg-pos mono">P{row.Position}</span> <span className="tyredeg-pos mono">P{row.Position}</span>
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} /> <span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</span> <span className="drv-code">{driverCode(row)}</span>
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span> <span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
<Meaning
value={tyreLabel(row.Tyre)}
meaning={ageAnnotation?.caption}
title={ageAnnotation?.title}
tone={ageAnnotation?.tone}
/>
</span>
{model ? ( {model ? (
<> <>
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}> <span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>

146
frontend/src/lib/meaning.ts Normal file
View File

@@ -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<Record<string, number>> = {
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',
}
}

View File

@@ -4,6 +4,8 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils' import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types' import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator' import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { Meaning } from '../components/Meaning'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator' type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
@@ -138,6 +140,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
color: teamColor(d.team_colour), color: teamColor(d.team_colour),
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`, gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`, gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
gapAheadNum,
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
spark: sparkPoints(d.form), spark: sparkPoints(d.form),
h2h: `${d.teammate_wins}${d.teammate_losses}`, h2h: `${d.teammate_wins}${d.teammate_losses}`,
h2hWin: d.teammate_wins >= d.teammate_losses, h2hWin: d.teammate_wins >= d.teammate_losses,
@@ -233,7 +237,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
</div> </div>
{view === 'drivers' && ( {view === 'drivers' && (
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} /> <DriversView
enriched={enriched}
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
/>
)} )}
{view === 'constructors' && <ConstructorsView hub={hub} />} {view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />} {view === 'progression' && <ProgressionView hub={hub} />}
@@ -248,6 +257,8 @@ interface EnrichedDriver {
color: string color: string
gapLeader: string gapLeader: string
gapAhead: string gapAhead: string
gapAheadNum: number | null
driverAhead: string | null
spark: string spark: string
h2h: string h2h: string
h2hWin: boolean h2hWin: boolean
@@ -259,10 +270,12 @@ function DriversView({
enriched, enriched,
leaderPoints, leaderPoints,
titleMath, titleMath,
roundsLeft,
}: { }: {
enriched: EnrichedDriver[] enriched: EnrichedDriver[]
leaderPoints: number leaderPoints: number
titleMath: string titleMath: string
roundsLeft: number
}) { }) {
const podium = enriched.slice(0, 3) const podium = enriched.slice(0, 3)
return ( return (
@@ -357,7 +370,19 @@ function DriversView({
<td className="champ-td-team">{e.d.team_name}</td> <td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td> <td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
<td className="r mono champ-td-muted">{e.gapLeader}</td> <td className="r mono champ-td-muted">{e.gapLeader}</td>
<td className="r mono champ-td-dim">{e.gapAhead}</td> <td className="r mono champ-td-dim">
{(() => {
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
return (
<Meaning
value={e.gapAhead}
meaning={gapAnnotation?.caption}
title={gapAnnotation?.title}
tone={gapAnnotation?.tone}
/>
)
})()}
</td>
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}> <td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins} {e.d.wins}
</td> </td>

View File

@@ -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;
}

View File

@@ -112,6 +112,7 @@ describe('ChampionshipPage', () => {
expect(screen.getAllByText('VER').length).toBeGreaterThan(0) expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument() expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title') 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 () => { it('switches to constructors and progression views', async () => {

View File

@@ -189,6 +189,26 @@ describe('TimingTower', () => {
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument() 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(
<TimingTower
rows={raceRows}
session={{
MeetingName: 'Monaco Grand Prix',
CircuitName: 'Monaco',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
}}
/>,
)
expect(screen.getByText('DRS range')).toBeInTheDocument()
})
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => { it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) => const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`), makeRow(String(index + 1), index + 1, `D${index + 1}`),

View File

@@ -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(<Meaning value="+1.234" meaning={null} />)
expect(screen.getByText('+1.234')).toBeInTheDocument()
expect(screen.queryByText('DRS range')).not.toBeInTheDocument()
})
it('renders value with caption and tooltip title', () => {
render(
<Meaning
value="+0.4"
meaning="DRS range"
title="Within 1.0s — DRS enabled next straight"
tone="good"
/>,
)
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')
})
})

View File

@@ -52,9 +52,18 @@ describe('TyreDegPanel', () => {
const panel = screen.getByTestId('tyredeg-panel') const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5') expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('fresh')
expect(panel).toHaveTextContent('warming up') 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(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
expect(screen.getByText('mid-life')).toBeInTheDocument()
})
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => { it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
const { rerender } = render( const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />, <TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { CompareView } from '../components/CompareView' import { CompareView } from '../components/CompareView'
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types' import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
@@ -158,13 +158,89 @@ const comparison: LapsComparisonResponse = {
], ],
} }
function renderCompareView() { const nextSessionResults: EnrichedResult[] = [
{
driver_number: 16,
position: 1,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'E8002D',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: null,
number_of_laps: 57,
points: 25,
session_key: 9550,
meeting_key: 1234,
},
{
driver_number: 55,
position: 2,
name_acronym: 'SAI',
full_name: 'Carlos Sainz',
team_name: 'Williams',
team_colour: '64C4FF',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 3.2,
number_of_laps: 57,
points: 18,
session_key: 9550,
meeting_key: 1234,
},
]
const nextSessionDrivers: Driver[] = [
{
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
first_name: 'Charles',
last_name: 'Leclerc',
team_name: 'Ferrari',
team_colour: 'E8002D',
headshot_url: '',
broadcast_name: 'C LECLERC',
session_key: 9550,
meeting_key: 1234,
},
{
driver_number: 55,
name_acronym: 'SAI',
full_name: 'Carlos Sainz',
first_name: 'Carlos',
last_name: 'Sainz',
team_name: 'Williams',
team_colour: '64C4FF',
headshot_url: '',
broadcast_name: 'C SAINZ',
session_key: 9550,
meeting_key: 1234,
},
]
function renderCompareView(
props: {
sessionKey?: number
results?: EnrichedResult[]
drivers?: Driver[]
} = {},
) {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, defaultOptions: { queries: { retry: false } },
}) })
return render( return render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<CompareView sessionKey={9472} results={results} drivers={drivers} /> <CompareView
sessionKey={props.sessionKey ?? 9472}
results={props.results ?? results}
drivers={props.drivers ?? drivers}
/>
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
@@ -305,6 +381,28 @@ describe('CompareView', () => {
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0) expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
}) })
it('resets the selected pair when the mounted session changes', async () => {
const { rerender } = renderCompareView()
fireEvent.change(screen.getByTestId('compare-picker-a'), { target: { value: '44' } })
expect(screen.getByTestId('compare-picker-a')).toHaveValue('44')
rerender(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<CompareView
sessionKey={9550}
results={nextSessionResults}
drivers={nextSessionDrivers}
/>
</QueryClientProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('compare-picker-a')).toHaveValue('16')
expect(screen.getByTestId('compare-picker-b')).toHaveValue('55')
})
})
it('renders telemetry and pace sections with mocked queries', async () => { it('renders telemetry and pace sections with mocked queries', async () => {
renderCompareView() renderCompareView()

View File

@@ -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()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 129 KiB