Compare commits

..

1 Commits

Author SHA1 Message Date
AmanTahiliani
cd282366be chore: add agentic development toolkit 2026-07-03 19:47:50 -04:00
170 changed files with 691 additions and 18256 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
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
You review and flip to `Ready`.
- **Implement** (recommended harnesses: `codex` or `cursor`): `.agents/bin/dev implement <issue#> --harness <name>`
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>`
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
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
@@ -42,17 +42,8 @@ local adapter state.
## Adding / fixing a harness
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
tool non-interactively in `<workdir>` on the prompt. For current implementation
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.
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting.
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
nothing (no worktree, PR, or state change).

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
#
# dev implement <issue#> --harness <codex|cursor|claude|opencode|pi|agy> [--dry-run] [--base <branch>]
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>]
#
# 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).
@@ -15,7 +15,7 @@ box-box dev CLI
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
harnesses: codex, cursor (recommended) · claude, opencode (available) · agy (dry-run only, disabled for dispatch) · pi (verify flags)
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh)
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
--base base branch for the worktree/PR (default: main)
EOF

View File

@@ -11,22 +11,9 @@
# ---- 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
local dir="$1" prompt="$2"
local model="${BOXBOX_CLAUDE_MODEL:-opus}"
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
}
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
@@ -39,19 +26,6 @@ harness_opencode() { # opencode — non-interactive run
( cd "$dir" && opencode run "$(cat "$prompt")" )
}
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
local dir="$1" prompt="$2"
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
}
harness_agy() { # Antigravity CLI — disabled until headless is verified
local reason
reason="$(harness_disabled_reason agy)"
echo "harness_agy: $reason" >&2
return 2
}
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----
harness_pi() { # pi — CONFIRM headless CLI + flags
@@ -59,6 +33,11 @@ harness_pi() { # pi — CONFIRM headless CLI + flags
( cd "$dir" && pi run "$(cat "$prompt")" ) # placeholder — verify
}
harness_cursor() { # Cursor CLI agent — CONFIRM flags
local dir="$1" prompt="$2"
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --force ) # placeholder — verify
}
# ---- build/typecheck gate (fast, local) ----
# Returns non-zero on failure. This is a smoke gate — CI runs the full suite. Tune freely.
run_gate() {

View File

@@ -52,15 +52,6 @@ dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
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
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
repo_root="$(git rev-parse --show-toplevel)" || return 1

View File

@@ -1,6 +1,6 @@
---
name: groom
description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
argument-hint: <issue-number> [--lens architect]
---

View File

@@ -1,6 +1,6 @@
---
name: implement
description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
---

View File

@@ -1,6 +1,6 @@
---
name: lens-architect
description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
argument-hint: <issue-number>
---

View File

@@ -1,6 +1,6 @@
---
name: review
description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
---

View File

@@ -1,6 +1,6 @@
---
name: write-spec
description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage.
argument-hint: <issue-number>
---

View File

@@ -1,59 +0,0 @@
#!/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
}

View File

@@ -27,7 +27,6 @@ func main() {
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)")
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
@@ -77,21 +76,11 @@ func main() {
if *ingestNews {
ingestFlags++
}
if *prefetchTrackOutlines != 0 {
ingestFlags++
}
if ingestFlags > 0 {
if ingestFlags > 1 {
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set")
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set")
os.Exit(1)
}
if *prefetchTrackOutlines != 0 {
if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil {
fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err)
os.Exit(1)
}
return
}
if *ingestNews {
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
@@ -99,7 +88,7 @@ func main() {
}
return
}
yearVal := *ingestYear
if *backfillSeason != 0 {
yearVal = *backfillSeason
@@ -185,35 +174,6 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo
return err
}
func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error {
log.SetOutput(os.Stderr)
fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year)
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return fmt.Errorf("fetch meetings for %d: %w", year, err)
}
result := client.PrefetchTrackOutlinesForYear(year, meetings)
fmt.Printf(
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n",
result.Year,
result.CachedBefore,
result.UniqueCircuits,
result.CachedAfter,
result.UniqueCircuits,
result.Skipped,
result.Fetched,
result.Failed,
)
if result.CachedAfter == 0 {
return fmt.Errorf("cached zero track outlines for %d", year)
}
return nil
}
func runNewsIngestion(dryRun bool, dbPath string) error {
log.SetOutput(os.Stderr)
@@ -315,7 +275,7 @@ func runCoverageReport(year int, dbPath string) error {
}
fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year)
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n",
"Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA")
fmt.Println(strings.Repeat("-", 82))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

View File

@@ -1,32 +0,0 @@
# v0.4.0 — Current Weekend & Product Truth
Product research references for the v0.4.0 feature sprint.
## Direction
The sprint replaces route-first navigation with a state-aware Weekend experience:
- Weekend is the adaptive home for what happened, what is live, and what is next.
- Preview content folds into Weekend before a session.
- Live remains a stable deep link and becomes Weekend's active-session state.
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
- Championship and Briefing remain dedicated destinations.
- Explore owns secondary discovery; Admin moves to operator utility.
## Mockups
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
## Constraints
- No OpenF1 REST dependency during active sessions.
- Public live GPS is not assumed to be available.
- Championship round numbers exclude tests and cancelled meetings.
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
- Future sessions must not render empty post-session analysis.
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,17 +1,11 @@
import type {
ArticleContent,
CarDataSample,
ChampionshipHub,
DriverSummary,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse,
LiveStateResponse,
LiveSessionMeta,
Meeting,
NewsItem,
RaceHub,
ReplayFramesResponse,
Session,
TrackOutline,
Weekend,
@@ -44,11 +38,7 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
}
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
return fetchMeetings(year, 'openf1')
}
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
@@ -56,47 +46,6 @@ export async function fetchMeetings(year: number, source = 'auto'): Promise<Meet
return Array.isArray(meetings) ? meetings : []
}
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const results = await res.json()
return Array.isArray(results) ? results : []
}
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const grid = await res.json()
return Array.isArray(grid) ? grid : []
}
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
if (!res.ok) return null
const data = await res.json()
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
}
export async function fetchReplayFrames(
sessionKey: number,
intervalMs = 5000,
): Promise<ReplayFramesResponse> {
const params = new URLSearchParams({
session_key: String(sessionKey),
interval_ms: String(intervalMs),
})
const res = await fetch(`/api/v1/replay/frames?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) {
@@ -125,19 +74,6 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
return res.json()
}
export async function fetchDriverSummary(
driverNumber: number,
year?: number,
): Promise<DriverSummary> {
const params = new URLSearchParams({ driver_number: String(driverNumber) })
if (year) params.set('year', String(year))
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state')
if (!res.ok) {
@@ -190,32 +126,3 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
body: JSON.stringify({ url: articleUrl }),
})
}
export async function fetchTelemetry(
sessionKey: number,
driverNumber: number,
): Promise<CarDataSample[]> {
const res = await fetch(
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const data = await res.json()
return Array.isArray(data) ? data : []
}
export async function fetchLapsComparison(
sessionKey: number,
drivers?: number[],
): Promise<LapsComparisonResponse> {
const params = new URLSearchParams({ session_key: String(sessionKey) })
if (drivers?.length) {
params.set('drivers', drivers.join(','))
}
const res = await fetch(`/api/v1/laps/comparison?${params}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

@@ -1,116 +0,0 @@
import { useEffect, useRef } from 'react'
import { BookOpen } from 'lucide-react'
import type { Chapter } from '../types'
import {
activeChapterIndex,
chapterKindLabel,
chapterLapRange,
chapterStartScrub,
} from '../lib/chapters'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/chapters.css'
interface Props {
chapters: Chapter[]
scrubTime: number | null
tMin: number
tRange: number
tourActive: boolean
tourChapterIndex: number | null
/** Explicit selection from a chapter click; wins over scrub-derived active. */
selectedChapterIndex?: number | null
onChapterClick: (index: number, scrub: number) => void
onTourToggle: () => void
}
export function ChapterStrip({
chapters,
scrubTime,
tMin,
tRange,
tourActive,
tourChapterIndex,
selectedChapterIndex = null,
onChapterClick,
onTourToggle,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
const highlightedIndex = tourActive
? tourChapterIndex
: (selectedChapterIndex ?? activeIndex)
useEffect(() => {
if (highlightedIndex === null || !scrollRef.current) return
const card = scrollRef.current.querySelector<HTMLElement>(
`[data-testid="chapter-card-${highlightedIndex}"]`,
)
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [highlightedIndex])
if (chapters.length === 0) {
return (
<div className="chapter-strip" data-testid="chapter-strip">
<EmptyStateCard
icon={BookOpen}
title="No story chapters"
hint="This session does not have enough race-control or position data to build narrative chapters."
testId="chapter-strip-empty"
className="chapter-strip-empty-card"
/>
</div>
)
}
return (
<div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header">
<span className="chapter-strip-title">Race chapters</span>
<div className="chapter-strip-actions">
<button
type="button"
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
onClick={onTourToggle}
aria-pressed={tourActive}
>
{tourActive ? 'Exit 90s' : '90s tour'}
</button>
</div>
</div>
<div className="chapter-strip-scroll-wrap">
<div
ref={scrollRef}
className="chapter-strip-scroll"
role="list"
aria-label="Race story chapters"
>
{chapters.map((chapter, index) => {
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
const isActive = highlightedIndex === index
const headline = chapter.headline || chapter.title
return (
<button
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button"
role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`}
>
<div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)}
</span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div>
<span className="chapter-headline">{headline}</span>
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,415 +0,0 @@
import { Link } from '@tanstack/react-router'
import { Activity, ArrowRight, Play } from 'lucide-react'
import { sessionTypeAbbrev } from '../lib/coverage'
import { countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, type HeroStateKind } from '../lib/hero'
import { sortLiveTimingRows, trackStatusInfo } from '../lib/live'
import {
formatCountdown,
formatSessionScheduleTime,
meetingStartTime,
sessionStartTime,
type FocusMeetingKind,
} from '../lib/schedule'
import type { EnrichedResult, LiveStreamData, Meeting, Session, WeekendSession } from '../types'
import '../styles/hero.css'
export interface CommandCenterHeroProps {
state: HeroStateKind
now: Date
accent: string
liveActive: boolean
liveData: LiveStreamData | null | undefined
focusMeeting: Meeting
focusKind: FocusMeetingKind
sessions: WeekendSession[]
currentSession: Session | null
nextSession: Session | null
analysisSessionKey?: number
analysisSessionName?: string
lastRaceName: string
lastRacePodium: EnrichedResult[]
lastRaceSessionKey?: number
nextMeeting: Meeting | null
}
export function CommandCenterHero({
state,
now,
accent,
liveActive,
liveData,
focusMeeting,
focusKind,
sessions,
currentSession,
nextSession,
analysisSessionKey,
analysisSessionName,
lastRaceName,
lastRacePodium,
lastRaceSessionKey,
nextMeeting,
}: CommandCenterHeroProps) {
const accentStyle = {
'--gp-accent': accent,
'--hero-accent': accent,
} as React.CSSProperties
return (
<div className="hero-panel cc-hero" data-testid="cc-focus">
<section className="hero-card ui-card glass-panel" style={accentStyle}>
<div
className={`hero-accent${state === 'live' ? ' hero-accent--live' : ''}`}
aria-hidden="true"
/>
<div className="hero-body">
<div className="hero-inner">
{state === 'live' && (
<LiveHero
liveActive={liveActive}
liveData={liveData}
currentSession={currentSession}
focusMeeting={focusMeeting}
/>
)}
{state === 'upcoming' && (
<UpcomingHero
now={now}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={sessions}
currentSession={currentSession}
nextSession={nextSession}
liveActive={liveActive}
analysisSessionKey={analysisSessionKey}
analysisSessionName={analysisSessionName}
/>
)}
{state === 'between' && (
<BetweenHero
now={now}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeeting}
analysisSessionKey={analysisSessionKey}
analysisSessionName={analysisSessionName}
/>
)}
</div>
</div>
</section>
</div>
)
}
function LiveHero({
liveActive,
liveData,
currentSession,
focusMeeting,
}: {
liveActive: boolean
liveData: LiveStreamData | null | undefined
currentSession: Session | null
focusMeeting: Meeting
}) {
const sessionName =
liveData?.Session?.SessionName ?? currentSession?.session_name ?? 'Live session'
const trackStatus = trackStatusInfo(liveData?.TrackStatus)
const topThree = sortLiveTimingRows(liveData).filter((r) => r.Position > 0).slice(0, 3)
const decal = countryDecal(focusMeeting)
return (
<>
<div className="hero-row">
<span className="hero-decal mono">{decal}</span>
<div className="hero-identity">
<div className="hero-eyebrow hero-eyebrow--live mono"> Live now</div>
<h1 className="hero-title">{sessionName}</h1>
<div className="hero-sub mono">
{[focusMeeting.meeting_name, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
</div>
<div className={`hero-track-status hero-track-status--${trackStatus.key}`}>
{trackStatus.label}
</div>
</div>
{topThree.length > 0 && (
<div className="hero-timing" data-testid="hero-live-timing">
{topThree.map((row) => (
<div key={row.RacingNumber} className="hero-timing-row">
<span className={`hero-timing-pos hero-timing-pos--p${row.Position}`}>
P{row.Position}
</span>
<span className="hero-timing-driver">
<span
className="hero-timing-bar"
style={{ background: `#${row.Info?.TeamColour ?? '9aa0a6'}` }}
/>
{row.Info?.Tla ?? row.RacingNumber}
</span>
<span className="hero-timing-gap">
{row.Position === 1 ? 'LEAD' : row.Driver.Interval || row.Driver.GapToLeader || '—'}
</span>
</div>
))}
</div>
)}
<div className="hero-countdown">
<div className="hero-cd-label mono">{liveActive ? 'SignalR' : 'On track'}</div>
<div className="hero-cd-value hero-cd-value--live mono">LIVE</div>
<div className="hero-cd-sub mono">{sessionName}</div>
</div>
</div>
<div className="hero-actions">
<Link to="/live" className="hero-cta hero-cta--primary" data-testid="hero-live-link">
Open Live Timing <ArrowRight size={16} />
</Link>
</div>
</>
)
}
function UpcomingHero({
now,
focusMeeting,
focusKind,
sessions,
currentSession,
nextSession,
liveActive,
analysisSessionKey,
analysisSessionName,
}: {
now: Date
focusMeeting: Meeting
focusKind: FocusMeetingKind
sessions: WeekendSession[]
currentSession: Session | null
nextSession: Session | null
liveActive: boolean
analysisSessionKey?: number
analysisSessionName?: string
}) {
const decal = countryDecal(focusMeeting)
const countdownTarget =
nextSession && sessionStartTime(nextSession)
? sessionStartTime(nextSession)!
: meetingStartTime(focusMeeting)
const kindLabel =
focusKind === 'current'
? 'Current weekend'
: focusKind === 'next'
? 'Next weekend'
: 'Weekend'
const sortedSessions = [...sessions].sort((a, b) => {
const left = sessionStartTime(a.session)?.getTime() ?? 0
const right = sessionStartTime(b.session)?.getTime() ?? 0
return left - right
})
return (
<>
<div className="hero-row">
<span className="hero-decal mono">{decal}</span>
<div className="hero-identity">
<div className="hero-eyebrow mono">{kindLabel}</div>
<h1 className="hero-title">{focusMeeting.meeting_name}</h1>
<div className="hero-sub mono">
{[focusMeeting.location, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')}
</div>
<div className="hero-sub mono">{formatGpDateRange(focusMeeting)}</div>
</div>
<div className="hero-countdown" data-testid="hero-countdown">
{nextSession ? (
<>
<div className="hero-cd-label mono">Next · {nextSession.session_name}</div>
{countdownTarget && (
<div className="hero-cd-value mono">{formatCountdown(countdownTarget, now)}</div>
)}
<div className="hero-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
</>
) : (
<>
<div className="hero-cd-label mono">Status</div>
<div className="hero-cd-value mono">Complete</div>
<div className="hero-cd-sub mono">Weekend finished</div>
</>
)}
</div>
</div>
{sortedSessions.length > 0 && (
<div className="hero-schedule-strip" data-testid="hero-schedule-strip" role="list">
{sortedSessions.map(({ session }) => {
const status = classifySessionStatus(session, now)
const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key
const isLive = isCurrent && liveActive
return (
<Link
key={session.session_key}
to="/race-hub"
search={{ session_key: session.session_key }}
className={`hero-schedule-card${isNext ? ' is-next' : ''}${
status === 'done' ? ' is-done' : ''
}${isLive ? ' is-live' : ''}`}
role="listitem"
>
<div className="hero-schedule-abbrev mono">
{sessionTypeAbbrev(session.session_type, session.session_name)}
</div>
<div className="hero-schedule-name">{session.session_name}</div>
<div className="hero-schedule-time mono">
{formatSessionScheduleTime(session.date_start)}
</div>
<div
className={`hero-schedule-marker mono${
isLive ? ' hero-schedule-marker--live' : isNext ? ' hero-schedule-marker--next' : ''
}`}
>
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
</div>
</Link>
)
})}
</div>
)}
<div className="hero-actions">
<Link
to="/live"
className="hero-secondary-link"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<Play size={14} /> Watch Live
</Link>
{analysisSessionKey != null && (
<Link
to="/race-hub"
search={{ session_key: analysisSessionKey }}
className="hero-secondary-link"
data-testid="hero-analysis-link"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<Activity size={14} /> Open Analysis
{analysisSessionName ? ` · ${analysisSessionName}` : ''}
</Link>
)}
</div>
</>
)
}
function BetweenHero({
now,
lastRaceName,
lastRacePodium,
lastRaceSessionKey,
nextMeeting,
analysisSessionKey,
analysisSessionName,
}: {
now: Date
lastRaceName: string
lastRacePodium: EnrichedResult[]
lastRaceSessionKey?: number
nextMeeting: Meeting | null
analysisSessionKey?: number
analysisSessionName?: string
}) {
const nextStart = nextMeeting ? meetingStartTime(nextMeeting) : null
const podium = lastRacePodium
.filter((r) => r.position >= 1 && r.position <= 3)
.sort((a, b) => a.position - b.position)
return (
<>
<div className="hero-row">
<div className="hero-identity">
<div className="hero-eyebrow mono">Between race weekends</div>
{lastRaceName && (
<h1 className="hero-title" data-testid="hero-last-race">
After {lastRaceName}
</h1>
)}
{!lastRaceName && <h1 className="hero-title">Season pause</h1>}
</div>
{podium.length > 0 && (
<div className="hero-podium" data-testid="hero-podium">
{podium.map((r) => (
<div key={r.driver_number} className="hero-podium-row">
<span className={`hero-podium-pos hero-podium-pos--p${r.position} mono`}>
P{r.position}
</span>
<span className="hero-podium-driver">
<span
className="hero-timing-bar"
style={{ background: `#${r.team_colour}` }}
/>
{r.name_acronym}
</span>
</div>
))}
</div>
)}
{nextMeeting && (
<div className="hero-countdown" data-testid="hero-next-gp-countdown">
<div className="hero-between-next">
{countryFlag(nextMeeting) && (
<span className="hero-flag" aria-hidden="true">
{countryFlag(nextMeeting)}
</span>
)}
<div>
<div className="hero-cd-label mono">Next GP</div>
<div className="hero-title" style={{ fontSize: '20px' }}>
{nextMeeting.meeting_name}
</div>
</div>
</div>
{nextStart && (
<>
<div className="hero-cd-value mono" style={{ marginTop: 'var(--s3)' }}>
{formatCountdown(nextStart, now)}
</div>
<div className="hero-cd-sub mono">{formatGpDateRange(nextMeeting)}</div>
</>
)}
</div>
)}
</div>
<div className="hero-actions">
{lastRaceSessionKey != null && (
<Link
to="/race-hub"
search={{ session_key: lastRaceSessionKey }}
className="hero-secondary-link"
data-testid="hero-last-race-link"
>
View {lastRaceName || 'last race'} results
</Link>
)}
{analysisSessionKey != null && analysisSessionKey !== lastRaceSessionKey && (
<Link
to="/race-hub"
search={{ session_key: analysisSessionKey }}
className="hero-secondary-link"
data-testid="hero-analysis-link"
>
Open Analysis{analysisSessionName ? ` · ${analysisSessionName}` : ''}
</Link>
)}
<Link to="/live" className="hero-secondary-link">
Live Timing
</Link>
</div>
</>
)
}

View File

@@ -1,253 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLapsComparison, fetchTelemetry } from '../api'
import {
buildBestLapTraceSeries,
compareDriverOptions,
comparisonToDeltaSeries,
defaultCompareDriverNumbers,
formatPitLapsCaption,
} from '../lib/compare'
import type { Driver, EnrichedResult } from '../types'
import { teamColor } from '../utils'
import { DriverCell } from './DriverCell'
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
import '../styles/compare-view.css'
interface Props {
sessionKey: number
results: EnrichedResult[]
drivers: Driver[]
}
function SectionState({
loading,
error,
empty,
emptyMessage,
children,
}: {
loading: boolean
error: Error | null
empty: boolean
emptyMessage: string
children: React.ReactNode
}) {
if (loading) {
return <div className="loading-state">loading</div>
}
if (error) {
return <div className="error-box">{error.message}</div>
}
if (empty) {
return <div className="missing-notice">{emptyMessage}</div>
}
return <>{children}</>
}
export function CompareView({ sessionKey, results, drivers }: Props) {
const driverOptions = useMemo(
() => compareDriverOptions(drivers, results),
[drivers, results],
)
const initialPair = useMemo(
() => defaultCompareDriverNumbers(results, drivers),
[results, drivers],
)
const previousSessionKey = useRef(sessionKey)
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
useEffect(() => {
if (previousSessionKey.current !== sessionKey) {
previousSessionKey.current = sessionKey
setDriverA(initialPair?.[0] ?? null)
setDriverB(initialPair?.[1] ?? null)
return
}
if (driverA != null && driverB != null) return
if (!initialPair) return
setDriverA(initialPair[0])
setDriverB(initialPair[1])
}, [sessionKey, initialPair, driverA, driverB])
const pair = useMemo((): [number, number] | null => {
if (driverA == null || driverB == null || driverA === driverB) return null
return [driverA, driverB]
}, [driverA, driverB])
const comparisonQuery = useQuery({
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
queryFn: () => fetchLapsComparison(sessionKey, pair!),
enabled: pair != null,
staleTime: 60_000,
})
const telemetryAQuery = useQuery({
queryKey: ['telemetry', sessionKey, pair?.[0]],
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
enabled: pair != null,
staleTime: 60_000,
})
const telemetryBQuery = useQuery({
queryKey: ['telemetry', sessionKey, pair?.[1]],
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
enabled: pair != null,
staleTime: 60_000,
})
const comparison = comparisonQuery.data
const referenceLabel = useMemo(() => {
if (!pair) return undefined
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
const session = drivers.find((d) => d.driver_number === pair[0])
return meta?.name_acronym || session?.name_acronym
}, [pair, comparison, drivers])
const deltaSeries = useMemo(() => {
if (!comparison || !pair) return []
return comparisonToDeltaSeries(comparison, pair, drivers)
}, [comparison, pair, drivers])
const pitCaption = useMemo(() => {
if (!comparison || !pair) return null
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
}, [comparison, pair, drivers])
const traceSeries = useMemo(() => {
if (!pair || !comparison) return []
const out = []
for (const dn of pair) {
const comp = comparison.drivers.find((d) => d.driver_number === dn)
const session = drivers.find((d) => d.driver_number === dn)
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
const color = teamColor(comp?.team_colour || session?.team_colour)
const carData =
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
if (series) out.push(series)
}
return out
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
if (driverOptions.length < 2) {
return (
<div className="missing-notice" data-testid="compare-view-empty">
Need at least two drivers in this session to compare.
</div>
)
}
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
return (
<div className="compare-view" data-testid="compare-view">
<div className="compare-pickers">
<div className="compare-picker">
<span className="compare-picker-label">Reference</span>
<select
className="compare-picker-select"
value={driverA ?? ''}
onChange={(e) => setDriverA(Number(e.target.value))}
data-testid="compare-picker-a"
>
{driverOptions.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · #{d.driver_number}
</option>
))}
</select>
{driverAInfo && (
<DriverCell
acronym={driverAInfo.name_acronym}
number={driverAInfo.driver_number}
colour={driverAInfo.team_colour}
/>
)}
</div>
<div className="compare-picker">
<span className="compare-picker-label">Challenger</span>
<select
className="compare-picker-select"
value={driverB ?? ''}
onChange={(e) => setDriverB(Number(e.target.value))}
data-testid="compare-picker-b"
>
{driverOptions.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · #{d.driver_number}
</option>
))}
</select>
{driverBInfo && (
<DriverCell
acronym={driverBInfo.name_acronym}
number={driverBInfo.driver_number}
colour={driverBInfo.team_colour}
/>
)}
</div>
</div>
{driverA === driverB && (
<div className="analysis-notice">
<strong>Pick two different drivers</strong> to run a comparison.
</div>
)}
<section className="compare-section" data-testid="compare-telemetry-section">
<div>
<div className="compare-section-title">Best lap telemetry</div>
<div className="compare-section-meta">
Speed, throttle, and brake traces for each driver&apos;s fastest lap
</div>
</div>
<SectionState
loading={telemetryLoading}
error={telemetryError instanceof Error ? telemetryError : null}
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
>
<TelemetryTraceChart series={traceSeries} />
</SectionState>
</section>
<section className="compare-section" data-testid="compare-pace-section">
<div>
<div className="compare-section-title">Race pace</div>
<div className="compare-section-meta">
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
only where the reference lap is valid; gaps appear when the reference has no lap
time.
</div>
</div>
<SectionState
loading={comparisonQuery.isLoading}
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
emptyMessage="No lap comparison data for the selected drivers."
>
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
{pitCaption && (
<p className="compare-pit-caption" data-testid="compare-pit-caption">
{pitCaption}
</p>
)}
</SectionState>
</section>
</div>
)
}

View File

@@ -1,20 +0,0 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
interface Props {
icon: LucideIcon
title: string
hint: ReactNode
testId?: string
className?: string
}
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
return (
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
<Icon size={32} className="empty-state-icon" aria-hidden />
<div className="empty-state-title">{title}</div>
<div className="empty-state-desc">{hint}</div>
</div>
)
}

View File

@@ -1,26 +0,0 @@
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

@@ -16,9 +16,6 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/preview" activeProps={{ className: 'active' }}>
Preview
</Link>
<Link to="/championship" activeProps={{ className: 'active' }}>
Championship
</Link>

View File

@@ -1,23 +1,7 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
fetchChampionshipHub,
fetchNews,
fetchSeasonMeetings,
fetchSeasons,
} from '../api'
import {
activeDigestWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
tagColour,
tagItems,
topTags,
} from '../lib/digest'
import { fetchNews } from '../api'
import { timeAgo } from '../utils'
import '../styles/digest.css'
const SOURCE_DISPLAY: Record<string, string> = {
'fia': 'FIA',
@@ -30,53 +14,14 @@ const SOURCE_DISPLAY: Record<string, string> = {
}
export function PaddockBriefing() {
const now = useMemo(() => new Date(), [])
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(100),
staleTime: 60_000,
})
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const meetings = meetingsQuery.data ?? []
const hub = hubQuery.data
const tagged = useMemo(
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
[news, hub],
)
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
const activeWindow = useMemo(
() => activeDigestWindow(windows, meetings, now),
[windows, meetings, now],
)
const sinceItems = useMemo(
() => itemsForWindow(tagged, activeWindow),
[tagged, activeWindow],
)
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
const sinceLabel = sinceLastLabel(meetings, now)
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
const preview = news?.slice(0, 5) ?? []
return (
<section className="cc-briefing" data-testid="paddock-briefing">
@@ -92,27 +37,6 @@ export function PaddockBriefing() {
</Link>
</div>
{meetings.length > 0 && (
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
<span>Since {sinceLabel}</span>
<span>·</span>
<span>{sinceItems.length} items</span>
{sinceTags.length > 0 && (
<div className="cc-brief-digest-tags">
{sinceTags.map((tag) => (
<span
key={tag.key}
className="cc-brief-tag"
style={{ borderColor: tagColour(tag.colour) }}
>
{tag.label}
</span>
))}
</div>
)}
</div>
)}
{isLoading && <div className="briefing-state loading-state">loading</div>}
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}

View File

@@ -1,36 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from 'lucide-react'
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip'
import { EmptyStateCard } from './EmptyStateCard'
import { useState, useMemo, useRef, useCallback } from 'react'
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
import {
chapterBandFill,
chapterEndScrub,
chapterStartScrub,
chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters'
import { isReplayMapAvailable } from '../lib/replayMap'
import '../styles/race-story.css'
const CHAPTER_TOUR_MS = 90_000
const CHART_W = 640
const CHART_H = 180
const CHART_PL = 40
const CHART_PR = 48
const CHART_PT = 8
const CHART_PB = 20
interface ChartTiming {
tMin: number
tMax: number
tRange: number
}
interface Props {
data: {
@@ -42,256 +12,38 @@ interface Props {
race_control?: any[]
pit_stops?: any[]
session?: Session
meeting?: Meeting
drivers?: Driver[]
chapters?: Chapter[]
}
}
/**
* Position samples are recorded for the entire session, including the pre-race
* grid period. Use the winning driver's laps to define the race window so
* that the x-axis starts at lights-out rather than at the earliest sample.
*/
function raceChartTiming(
positions: PositionSample[],
laps: Lap[],
results: EnrichedResult[],
): ChartTiming | null {
const positionTimes = positions
.map((position) => new Date(position.date).getTime())
.filter(Number.isFinite)
if (positionTimes.length === 0) return null
const fallbackMin = Math.min(...positionTimes)
const fallbackMax = Math.max(...positionTimes)
const fallback = {
tMin: fallbackMin,
tMax: fallbackMax,
tRange: Math.max(fallbackMax - fallbackMin, 1),
}
const winner = results.find((result) => result.position === 1)
if (!winner) return fallback
const winnerLaps = laps
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
.filter((lap) => Number.isFinite(lap.start))
.sort((a, b) => a.lap_number - b.lap_number)
if (winnerLaps.length === 0) return fallback
const firstLap = winnerLaps[0]
const lastLap = winnerLaps[winnerLaps.length - 1]
const finalLapDuration = lastLap.lap_duration ?? 0
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
if (tMax <= firstLap.start) return fallback
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
}
export function RaceStoryCanvas({ data }: Props) {
const {
results,
starting_grid: grid,
positions,
datasets,
race_control = [],
pit_stops = [],
laps = [],
session,
meeting,
drivers = [],
chapters = [],
} = data
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
const hasPositions = datasets['positions']?.status === 'available'
const [scrubTime, setScrubTime] = useState<number | null>(null)
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
const [mapOpen, setMapOpen] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
const hasChartData = hasPositions && chartTiming !== null
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
const replayQuery = useQuery({
queryKey: ['replay-frames', session?.session_key, 5000],
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
enabled: canProbeMap,
})
const outlineQuery = useQuery({
queryKey: ['track-outline', circuitKey, outlineYear],
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
enabled: canProbeMap,
})
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
const mapAvailable = useMemo(
() =>
canProbeMap &&
isReplayMapAvailable(
replayQuery.data,
outlineQuery.data,
replayQuery.isError || outlineQuery.isError,
),
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
)
const showMapPanel = mapOpen && mapAvailable
useEffect(() => {
if (!mapAvailable && mapOpen) {
setMapOpen(false)
}
}, [mapAvailable, mapOpen])
useEffect(() => {
if (!isPlaying || !chartTiming) return
let frame = 0
let last = performance.now()
const tick = (now: number) => {
const delta = now - last
last = now
setScrubTime((current) => {
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
if (next >= 1) {
setIsPlaying(false)
}
return next
})
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [chartTiming, isPlaying, playbackSpeed])
const stopChapterTour = () => {
setChapterTourActive(false)
setTourChapterIndex(null)
}
const jumpToChapter = (index: number, scrub: number) => {
setIsPlaying(false)
stopChapterTour()
setSelectedChapterIndex(index)
setScrubTime(scrub)
}
const clearChapterSelection = () => {
setSelectedChapterIndex(null)
}
const toggleChapterTour = () => {
if (chapterTourActive) {
stopChapterTour()
return
}
if (!chartTiming || chapters.length === 0) return
setIsPlaying(false)
setSelectedChapterIndex(null)
setChapterTourActive(true)
setTourChapterIndex(0)
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
setScrubTime(startScrub)
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
tourRef.current = {
chapterIndex: 0,
startedAt: performance.now(),
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
startScrub,
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
}
}
useEffect(() => {
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
let frame = 0
const tick = (now: number) => {
const state = tourRef.current
const elapsed = now - state.startedAt
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
setScrubTime(scrub)
setTourChapterIndex(state.chapterIndex)
if (progress >= 1) {
const nextIndex = state.chapterIndex + 1
if (nextIndex >= chapters.length) {
stopChapterTour()
return
}
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
tourRef.current = {
chapterIndex: nextIndex,
startedAt: now,
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
startScrub,
endScrub,
}
}
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [chapterTourActive, chartTiming, chapters])
useEffect(() => {
if (!chapterTourActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
stopChapterTour()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [chapterTourActive])
const replayTMs = useMemo(() => {
const replay = replayQuery.data
const frames = replay?.frames ?? []
if (frames.length === 0) return 0
const lastFrameT = frames[frames.length - 1].t
const progress = scrubTime ?? 0
if (!chartTiming || !replay?.start_time) {
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
}
const replayStart = new Date(replay.start_time).getTime()
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
}, [chartTiming, replayQuery.data, scrubTime])
// Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0
let chartContent = null
let displayResults = results
if (hasChartData && chartTiming) {
const { tMin, tRange } = chartTiming
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
if (hasChartData) {
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
const tRange = Math.max(tMax - tMin, 1)
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) {
const time = new Date(p.date).getTime()
if (!Number.isFinite(time)) continue
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({
t: normaliseTime(time),
t: (new Date(p.date).getTime() - tMin) / tRange,
pos: p.position,
})
}
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) {
@@ -299,16 +51,16 @@ export function RaceStoryCanvas({ data }: Props) {
}
}
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
if (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i + 1].t - samples[i].t
if (samples[i].t <= t && samples[i+1].t >= t) {
const dt = samples[i+1].t - samples[i].t
if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
}
}
return null
@@ -332,46 +84,43 @@ export function RaceStoryCanvas({ data }: Props) {
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = CHART_W
const H = CHART_H
const PL = CHART_PL
const PR = CHART_PR
const PT = CHART_PT
const PB = CHART_PB
const W = 640
const H = 180
const PL = 40
const PR = 48
const PT = 8
const PB = 20
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number; t: number }[] = []
const winner = results.find(r => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10
for (let i = 0; i < winnerLaps.length; i++) {
const lap = winnerLaps[i]
for (const lap of winnerLaps) {
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const lapStart = new Date(lap.date_start).getTime()
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
const lapEnd = lap.lap_duration && lap.lap_duration > 0
? lapStart + lap.lap_duration * 1000
: nextLapStart
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
if (t >= 0 && t <= 1) {
lapTicks.push({ lap: lap.lap_number, t })
}
}
}
// Safety Car / VSC periods
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
for (const msg of rc) {
const t = new Date(msg.date).getTime()
const m = msg.message?.toUpperCase() || ''
const cat = msg.category?.toUpperCase() || ''
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'VSC' }
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
@@ -387,73 +136,23 @@ export function RaceStoryCanvas({ data }: Props) {
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
}
const chapterBands = chapters
.map((chapter, index) => {
const startT = chapterStartScrub(chapter, tMin, tRange)
const endT = chapterEndScrub(chapter, tMin, tRange)
if (startT === null) return null
const end = endT ?? startT
return {
key: `${chapter.kind}-${index}`,
start: Math.min(startT, end),
end: Math.max(startT, end),
fill: chapterBandFill(chapter.kind),
}
})
.filter((band): band is NonNullable<typeof band> => band !== null)
const labelCandidates = Array.from(byDriver.entries())
.map(([dNum, samples]) => {
const last = samples[samples.length - 1]
if (!last) return null
return { key: dNum, y: toY(last.pos) }
})
.filter((item): item is { key: number; y: number } => item !== null)
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false)
stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
if (rect.width <= 0) return
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
// before comparing with the fixed chart margins and plot width.
const x = ((e.clientX - rect.left) / rect.width) * W
const x = e.clientX - rect.left
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
setScrubTime(t)
}
chartContent = (
<div
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
data-testid="position-chart"
>
<div className="rs-chart-container scroll-x" data-testid="position-chart">
<svg
ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
aria-label="Position evolution chart"
>
{chapterBands.map((band) => {
const x1 = toX(Math.max(0, band.start))
const x2 = toX(Math.min(1, band.end))
if (x2 <= PL || x1 >= W - PR) return null
return (
<rect
key={band.key}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={band.fill}
data-testid="chapter-band"
/>
)
})}
{scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1
@@ -472,41 +171,33 @@ export function RaceStoryCanvas({ data }: Props) {
)
})}
{positionLabels.map((pos) => (
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
className="rs-chart-grid-line"
stroke="var(--border)"
strokeWidth={0.5}
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
className="rs-chart-axis-label"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
>
P{pos}
</text>
</g>
))}
{lapTicks.map((tick) => (
{lapTicks.map(tick => (
<g key={`lap-${tick.lap}`}>
<line
x1={toX(tick.t)}
x2={toX(tick.t)}
y1={H - PB}
y2={H - PB + 4}
className="rs-chart-lap-tick"
/>
<text
x={toX(tick.t)}
y={H - PB + 14}
textAnchor="middle"
className="rs-chart-axis-label"
>
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
L{tick.lap}
</text>
</g>
@@ -519,12 +210,11 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
return (
<g
<g
key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)}
@@ -540,31 +230,30 @@ export function RaceStoryCanvas({ data }: Props) {
className="rs-driver-line"
pathLength={1}
/>
{driverPits.map((p, i) => {
const time = new Date(p.date).getTime()
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
const t = normaliseTime(time)
const t = (new Date(p.date).getTime() - tMin) / tRange
if (t < 0 || t > 1) return null
const pos = getInterpPos(samples, t)
if (pos === null) return null
return (
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
<circle
key={`pit-${i}`}
cx={toX(t)}
cy={toY(pos)}
r={3}
fill="var(--bg)"
stroke={color}
strokeWidth={2}
className="rs-pit-dot"
/>
)
})}
{last && (
<text
x={toX(last.t) + 6}
y={labelY + 4}
y={toY(last.pos) + 4}
fill={color}
fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)"
@@ -598,110 +287,25 @@ export function RaceStoryCanvas({ data }: Props) {
width={plotW}
height={plotH}
fill="transparent"
data-testid="position-chart-interaction"
onPointerMove={handlePointerMove}
onPointerLeave={() => {
if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}}
onPointerLeave={() => setScrubTime(null)}
style={{ cursor: 'crosshair', touchAction: 'none' }}
/>
</svg>
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
<button
type="button"
className={`rs-segment ${isPlaying ? 'active' : ''}`}
onClick={() => {
setScrubTime((current) => current ?? 0)
stopChapterTour()
clearChapterSelection()
setIsPlaying((current) => !current)
}}
aria-pressed={isPlaying}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<span className="rs-segment-divider" aria-hidden />
{[1, 10, 30].map((speed) => (
<button
key={speed}
type="button"
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)}
aria-pressed={playbackSpeed === speed}
>
{speed}x
</button>
))}
{mapProbeSettled && mapAvailable && (
<>
<span className="rs-segment-divider" aria-hidden />
<button
type="button"
className={`rs-segment ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
aria-pressed={mapOpen}
data-testid="replay-map-toggle"
>
Map
</button>
</>
)}
</div>
</div>
)
}
return (
<div className="race-story-canvas">
{hasChartData && chartTiming && chapters.length > 0 && (
<ChapterStrip
chapters={chapters}
scrubTime={scrubTime}
tMin={chartTiming.tMin}
tRange={chartTiming.tRange}
tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex}
selectedChapterIndex={selectedChapterIndex}
onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour}
/>
)}
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
<div className="rs-replay-main">
{hasChartData ? (
chartContent
) : (
<EmptyStateCard
icon={LineChart}
title="Lap-by-lap positions not available"
hint={
<>
This session does not have ingested position samples in{' '}
<code>/api/v1/race-hub</code>.
</>
}
testId="race-story-no-positions"
className="race-story-empty-card"
/>
)}
{hasChartData ? (
chartContent
) : (
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>.
</div>
{showMapPanel && (
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
<ReplayTrackMap
outline={outlineQuery.data}
replay={replayQuery.data}
tMs={replayTMs}
drivers={drivers}
results={results}
loading={outlineQuery.isLoading || replayQuery.isLoading}
error={outlineQuery.isError || replayQuery.isError}
/>
</div>
)}
</div>
)}
{displayResults.length > 0 && (
<div className="rs-field-list">
@@ -709,29 +313,30 @@ export function RaceStoryCanvas({ data }: Props) {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1
const pClass =
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
let currentPoints: number | string = r.points
if (scrubTime !== null) {
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint')
const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1]
currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
}
return (
<div
key={r.driver_number}
<div
key={r.driver_number}
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
onMouseEnter={() => setHoverDriver(r.driver_number)}
onMouseLeave={() => setHoverDriver(null)}
>
<div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
<div className={`rs-pos-col ${pClass}`}>
{currentPos}
</div>
<div className="rs-driver-cell">
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/>
<div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
@@ -749,12 +354,12 @@ export function RaceStoryCanvas({ data }: Props) {
</span>
<span className="rs-metric-label">Grid</span>
</div>
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div>
<div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{currentPoints}

View File

@@ -1,159 +0,0 @@
import { useMemo, useState } from 'react'
import { Loader2, MapPin, Satellite } from 'lucide-react'
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
import { buildOutlinePath } from '../lib/trackmap'
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/replay-map.css'
interface Props {
outline?: TrackOutline | null
replay?: ReplayFramesResponse | null
tMs: number
drivers: Driver[]
results: EnrichedResult[]
loading?: boolean
error?: boolean
}
export function ReplayTrackMap({
outline,
replay,
tMs,
drivers,
results,
loading = false,
error = false,
}: Props) {
const [pinned, setPinned] = useState<string | null>(null)
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const driverInfo = useMemo(() => {
const info = new Map<string, { label: string; color: string }>()
for (const driver of drivers) {
info.set(String(driver.driver_number), {
label: driver.name_acronym || String(driver.driver_number),
color: normalizeColor(driver.team_colour),
})
}
for (const result of results) {
const key = String(result.driver_number)
if (!info.has(key)) {
info.set(key, {
label: result.name_acronym || key,
color: normalizeColor(result.team_colour),
})
}
}
return info
}, [drivers, results])
const cars = useMemo(() => {
if (!outline?.bounds || !replay?.frames?.length) return []
const positions = interpolateReplayCars(replay.frames, tMs)
return Object.entries(positions)
.map(([number, car]) => ({
number,
svg: replayCarToSvg(car, outline.bounds),
info: driverInfo.get(number),
}))
.sort((a, b) => Number(a.number) - Number(b.number))
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
if (loading) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Loader2}
title="Loading replay map"
hint="Fetching track outline and historical GPS frames."
testId="replay-map-loading"
className="replay-map-empty-card"
/>
</section>
)
}
if (error) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Satellite}
title="Replay GPS unavailable"
hint="This session does not have ingested location samples for the replay map."
testId="replay-map-error"
className="replay-map-empty-card"
/>
</section>
)
}
if (!outline || !outlinePath) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={MapPin}
title="Track outline unavailable"
hint="Circuit GPS outline data is missing for this session."
testId="replay-map-no-outline"
className="replay-map-empty-card"
/>
</section>
)
}
if (!replay?.frames?.length || cars.length === 0) {
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<EmptyStateCard
icon={Satellite}
title="Historical GPS unavailable"
hint="Fewer than two replay frames were returned for this session."
testId="replay-map-no-frames"
className="replay-map-empty-card"
/>
</section>
)
}
return (
<section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-stage">
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
<path className="replay-map-outline-shadow" d={outlinePath} />
<path className="replay-map-outline" d={outlinePath} />
{cars.map((car) => {
const label = car.info?.label ?? car.number
const selected = pinned === car.number
return (
<g
key={car.number}
role="button"
tabIndex={0}
aria-label={`${label} replay position`}
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
onClick={() => setPinned(selected ? null : car.number)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setPinned(selected ? null : car.number)
}
}}
>
<title>{label}</title>
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
<text y="0.85">{label}</text>
</g>
)
})}
</svg>
</div>
</section>
)
}
function normalizeColor(color: string | undefined): string {
const raw = color?.trim()
if (!raw) return '#777777'
return raw.startsWith('#') ? raw : `#${raw}`
}

View File

@@ -1,271 +0,0 @@
import { useMemo, useState } from 'react'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { teamColor } from '../utils'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
import '../styles/rivalry.css'
const PAD_L = 48
const PAD_T = 16
const PLOT_W = 882
const PLOT_H = 316
const GAP_PLOT_H = 200
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Up to ~7 evenly spaced round labels, always including the last round. */
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
const step = Math.max(1, Math.ceil(n / 7))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += step) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
const last = labels[n - 1] ?? `R${n}`
if (ticks[ticks.length - 1]?.label !== last) {
ticks.push({ x: x(n - 1), label: last })
}
return ticks
}
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
}
interface PickerProps {
id: 'a' | 'b'
label: string
drivers: ChampHubDriver[]
value: number
color: string
onChange: (driverNumber: number) => void
}
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
return (
<label className="rivalry-picker">
<span className="rivalry-picker-label">{label}</span>
<select
className="rivalry-picker-select"
style={{ borderLeft: `3px solid ${color}` }}
value={value}
data-testid={`rivalry-pick-${id}`}
onChange={(e) => onChange(Number(e.target.value))}
>
{drivers.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · {d.full_name}
</option>
))}
</select>
</label>
)
}
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
const { drivers } = hub
const [pickA, setPickA] = useState<number | null>(null)
const [pickB, setPickB] = useState<number | null>(null)
// Default to the top two in the standings; fall back there if a picked
// driver disappears (e.g. season switch re-fetches the hub).
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
const tally = useMemo(
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
[a, b, hub.round_labels],
)
if (drivers.length < 2) {
return (
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
Need at least two drivers in the standings to compare a rivalry.
</div>
)
}
const colorA = teamColor(a.team_colour)
const colorB = teamColor(b.team_colour)
const sameTeam = a.team_name === b.team_name
const cumA = a.cumulative ?? []
const cumB = b.cumulative ?? []
const rounds = Math.min(cumA.length, cumB.length)
const gaps = gapSeries(cumA, cumB)
const lastGap = gaps[gaps.length - 1] ?? 0
const strip = tally ? lastRounds(tally, 5) : []
const pickers = (
<div className="rivalry-pickers">
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
<span className="rivalry-vs mono">vs</span>
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
</div>
)
if (rounds === 0) {
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="champ-chart-empty">
No completed rounds yet the rivalry will appear after the first race.
</div>
</div>
)
}
// Points race scales.
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
const pxGrid = xTicks(rounds, hub.round_labels, px)
// Gap scales: symmetric around zero.
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
const gxGrid = xTicks(rounds, hub.round_labels, px)
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
const gapCaption = gapLeader
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
: 'Dead level on points.'
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="rivalry-h2h" data-testid="rivalry-h2h">
<div className="rivalry-h2h-score">
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
{a.name_acronym}
</span>
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
{tally ? `${tally.a}${tally.b}` : '—'}
</span>
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
{b.name_acronym}
</span>
</div>
<span className="rivalry-h2h-meta">
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
</span>
{strip.length > 0 && (
<div className="rivalry-strip" data-testid="rivalry-strip">
<span className="rivalry-strip-label">Last {strip.length}</span>
{strip.map((r) => (
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
<span className="rivalry-chip-round mono">{r.label}</span>
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
</span>
</span>
))}
</div>
)}
</div>
<section className="rivalry-section" data-testid="rivalry-points-race">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Points race {a.name_acronym} vs {b.name_acronym}
</span>
<span className="champ-chart-meta">
Rounds 1{rounds} · {hub.season}
</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
{pyGrid.map((g) => (
<g key={g.label}>
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{pxGrid.map((g, i) => (
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
{[
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
].map((s) => (
<g key={s.slot}>
<polyline
points={polyline(s.values, px, py)}
fill="none"
stroke={s.color}
strokeWidth="2.4"
strokeDasharray={s.dash}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
<text
x={px(s.values.length - 1) + 8}
y={py(s.values[s.values.length - 1]) + 4}
fill={s.color}
className="champ-chart-label"
>
{s.d.name_acronym}
</text>
</g>
))}
</svg>
</div>
</section>
<section className="rivalry-section" data-testid="rivalry-gap">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Gap over season {a.name_acronym} {b.name_acronym}
</span>
<span className="champ-chart-meta">{gapCaption}</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
{gyGrid.map((g) => (
<g key={g.label}>
<line
x1={PAD_L}
y1={g.y}
x2={930}
y2={g.y}
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
strokeWidth={g.label === '0' ? 1.5 : 1}
/>
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{gxGrid.map((g, i) => (
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
<polyline
points={polyline(gaps, px, gy)}
fill="none"
stroke={colorA}
strokeWidth="2.4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
</svg>
</div>
<p className="rivalry-caption">
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
</p>
</section>
</div>
)
}

View File

@@ -1,9 +1,28 @@
import type { EnrichedResult, Stint, PitStop } from '../types'
import { compareFinishPosition } from '../utils'
import {
TyreStintTimeline,
type StintTimelineRow,
} from './charts/TyreStintTimeline'
const COMPOUND_COLORS: Record<string, string> = {
SOFT: '#e8002d',
MEDIUM: '#ffd600',
HARD: '#e8e8e4',
INTERMEDIATE: '#39b54a',
WET: '#0067ff',
}
function compoundColor(c: string): string {
return COMPOUND_COLORS[c.toUpperCase()] ?? '#666'
}
function compoundInitial(c: string): string {
const abbr: Record<string, string> = {
SOFT: 'S',
MEDIUM: 'M',
HARD: 'H',
INTERMEDIATE: 'I',
WET: 'W',
}
return abbr[c.toUpperCase()] ?? c[0] ?? '?'
}
interface Props {
results: EnrichedResult[]
@@ -70,33 +89,147 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
const cmp = compareFinishPosition(a.position, b.position)
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
})
const totalLaps = Math.max(
...stints.map((s) => s.lap_end),
...results.map((r) => r.number_of_laps),
1,
1
)
const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
label: driver.name_acronym || String(driver.driver_number),
color: driver.team_colour ? `#${driver.team_colour}` : '#888',
stints: stints
.filter((s) => s.driver_number === driver.driver_number)
.map((s) => ({
compound: s.compound,
lapStart: s.lap_start,
lapEnd: s.lap_end,
isNew: s.tyre_age_at_start === 0,
})),
pitStops: pit_stops
.filter((p) => p.driver_number === driver.driver_number)
.map((p) => p.lap_number)
.sort((a, b) => a - b),
}))
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const BAR_W = SVG_W - LEFT - RIGHT
const SVG_H = sortedDrivers.length * ROW_H + 8
const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W
const stintW = (s: Stint) =>
Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W)
const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter(
(c) => c in COMPOUND_COLORS
)
return (
<div data-testid="strategy-chart">
<TyreStintTimeline rows={timelineRows} totalLaps={totalLaps} />
<div className="scroll-x">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
style={{ width: '100%', minWidth: 280, maxWidth: SVG_W, display: 'block' }}
role="img"
aria-label="Race strategy stint chart"
>
{sortedDrivers.map((driver, i) => {
const rowY = i * ROW_H
const color = driver.team_colour ? `#${driver.team_colour}` : '#888'
const dStints = stints.filter((s) => s.driver_number === driver.driver_number)
const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number)
return (
<g key={driver.driver_number} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={color}
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={10}
>
{driver.name_acronym}
</text>
{dStints.map((stint, si) => {
const x = lapX(stint.lap_start)
const w = stintW(stint)
const fill = compoundColor(stint.compound)
return (
<g key={si}>
<rect x={x} y={BAR_Y} width={w} height={BAR_H} fill={fill} rx={1.5} />
{w > 18 && (
<text
x={x + w / 2}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="middle"
fill="#111"
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={8}
>
{compoundInitial(stint.compound)}
</text>
)}
</g>
)
})}
{dPits.map((pit, pi) => {
const x = lapX(pit.lap_number)
return (
<line
key={pi}
x1={x}
x2={x}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
stroke="var(--text)"
strokeWidth={1.5}
opacity={0.7}
/>
)
})}
</g>
)
})}
</svg>
</div>
<div
style={{
display: 'flex',
gap: 'var(--s4)',
flexWrap: 'wrap',
marginTop: 'var(--s4)',
fontSize: 11,
color: 'var(--text-3)',
alignItems: 'center',
}}
>
{usedCompounds.map((c) => (
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
width: 10,
height: 10,
background: COMPOUND_COLORS[c],
borderRadius: 2,
display: 'inline-block',
border: c === 'HARD' ? '1px solid #555' : undefined,
}}
/>
{c.charAt(0) + c.slice(1).toLowerCase()}
</span>
))}
{pit_stops.length > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginLeft: 4 }}>
<span
style={{
width: 1,
height: 12,
background: 'var(--text)',
display: 'inline-block',
opacity: 0.7,
}}
/>
Pit stop
</span>
)}
<span style={{ marginLeft: 'auto', fontFamily: 'var(--f-mono)', fontSize: 10 }}>
{totalLaps} laps
</span>
</div>
</div>
)
}

View File

@@ -2,7 +2,6 @@ export type Tab =
| 'overview'
| 'race_story'
| 'strategy'
| 'compare'
| 'lap_data'
| 'conditions'
| 'race_control'
@@ -12,7 +11,6 @@ const TABS: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'race_story', label: 'Race Story' },
{ id: 'strategy', label: 'Strategy' },
{ id: 'compare', label: 'Compare' },
{ id: 'lap_data', label: 'Lap Data' },
{ id: 'conditions', label: 'Conditions' },
{ id: 'race_control', label: 'Race Control' },

View File

@@ -1,55 +0,0 @@
import { teamColor } from '../utils'
import '../styles/h2h.css'
export interface TeammateH2HProps {
teamName: string
teamColour: string
driverATla: string
driverBTla: string
winsA: number
winsB: number
/** e.g. "+1" when more than two drivers share the team. */
extraNote?: string
}
export function TeammateH2H({
teamName,
teamColour,
driverATla,
driverBTla,
winsA,
winsB,
extraNote,
}: TeammateH2HProps) {
const total = winsA + winsB || 1
const pctA = (winsA / total) * 100
const color = teamColor(teamColour)
return (
<div className="h2h-row" data-testid="teammate-h2h">
<div className="h2h-team">
<span className="h2h-team-name">{teamName}</span>
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
</div>
<div className="h2h-bar-wrap">
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
<div className="h2h-bar" data-testid="h2h-bar">
<span
className="h2h-bar-seg"
data-testid="h2h-bar-a"
style={{ width: `${pctA}%`, background: color }}
/>
<span
className="h2h-bar-seg h2h-bar-seg--muted"
data-testid="h2h-bar-b"
style={{ width: `${100 - pctA}%`, background: color }}
/>
<span className="h2h-score mono" data-testid="h2h-score">
{winsA}{winsB}
</span>
</div>
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
</div>
</div>
)
}

View File

@@ -1,282 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import {
computeCumulativeDeltas,
deltaPolylineSegments,
formatDeltaSeconds,
type DeltaSeries,
type DriverDeltaResult,
} from '../../lib/delta'
import '../../styles/delta-graph.css'
export type { DeltaSeries }
export interface DeltaTimeGraphProps {
series: DeltaSeries[]
referenceLabel?: string
}
const W = 640
const H = 220
const PL = 44
const PR = 16
const PT = 12
const PB = 28
function lapCount(series: ReadonlyArray<DeltaSeries>): number {
return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
}
function yExtent(drivers: ReadonlyArray<DriverDeltaResult>): { min: number; max: number } {
let min = 0
let max = 0
for (const driver of drivers) {
for (const delta of driver.deltas) {
if (delta === null) continue
min = Math.min(min, delta)
max = Math.max(max, delta)
}
}
if (min === max) {
const pad = 1
return { min: min - pad, max: max + pad }
}
const span = max - min
const pad = span * 0.08
return { min: min - pad, max: max + pad }
}
function niceYTicks(min: number, max: number): number[] {
const span = max - min
if (span <= 0) return [0]
const rough = span / 4
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)))
const step = Math.ceil(rough / magnitude) * magnitude
const ticks: number[] = []
const start = Math.ceil(min / step) * step
for (let v = start; v <= max + step * 0.01; v += step) {
ticks.push(Number(v.toFixed(6)))
}
if (!ticks.some((t) => Math.abs(t) < step * 0.01)) {
ticks.push(0)
ticks.sort((a, b) => a - b)
}
return ticks
}
export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const [hoverLap, setHoverLap] = useState<number | null>(null)
const drivers = useMemo(
() => computeCumulativeDeltas(series, referenceLabel),
[series, referenceLabel],
)
const laps = useMemo(() => lapCount(series), [series])
const plotW = W - PL - PR
const plotH = H - PT - PB
const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers])
const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax])
const toX = useCallback(
(lapIndex: number) => {
if (laps <= 1) return PL + plotW / 2
return PL + (lapIndex / (laps - 1)) * plotW
},
[laps, plotW],
)
const toY = useCallback(
(delta: number) => {
const span = yMax - yMin || 1
return PT + ((delta - yMin) / span) * plotH
},
[yMin, yMax, plotH],
)
const lapTickNumbers = useMemo(() => {
const ticks: number[] = []
for (let lap = 5; lap <= laps; lap += 5) {
ticks.push(lap)
}
return ticks
}, [laps])
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
if (!svgRef.current || laps === 0) return
const rect = svgRef.current.getBoundingClientRect()
if (rect.width <= 0) return
const x = ((e.clientX - rect.left) / rect.width) * W
const frac = Math.max(0, Math.min(1, (x - PL) / plotW))
const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1))
if (!Number.isFinite(lapIndex)) return
setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex)))
},
[laps, plotW],
)
const handlePointerLeave = useCallback(() => setHoverLap(null), [])
if (series.length === 0 || laps === 0) {
return (
<div className="delta-graph" data-testid="delta-time-graph-empty">
<p className="delta-graph-empty">No lap data to compare.</p>
</div>
)
}
if (drivers.length === 0) {
return (
<div className="delta-graph" data-testid="delta-time-graph-empty">
<p className="delta-graph-empty">Select at least two drivers to compare.</p>
</div>
)
}
const hoverX = hoverLap !== null ? toX(hoverLap) : null
const tooltipRows = hoverLap !== null
? drivers
.map((d) => {
const delta = d.deltas[hoverLap]
if (delta == null) return null
return { label: d.label, color: d.color, delta }
})
.filter((row): row is { label: string; color: string; delta: number } => row !== null)
: []
const tooltipH = 18 + tooltipRows.length * 14
const tooltipW = 120
const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0
const tooltipY = PT
return (
<div className="delta-graph" data-testid="delta-time-graph">
<svg
ref={svgRef}
className="delta-graph-svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Cumulative delta time chart"
>
{yTicks.map((tick) => (
<g key={`y-${tick}`}>
<line
x1={PL}
x2={W - PR}
y1={toY(tick)}
y2={toY(tick)}
className={Math.abs(tick) < 1e-9 ? 'delta-graph-zero-line' : 'delta-graph-grid-line'}
data-testid={Math.abs(tick) < 1e-9 ? 'delta-zero-line' : undefined}
/>
<text
x={PL - 6}
y={toY(tick) + 3}
textAnchor="end"
className="delta-graph-axis-label"
>
{formatDeltaSeconds(tick)}
</text>
</g>
))}
{lapTickNumbers.map((lap) => {
const x = toX(lap - 1)
return (
<g key={`lap-${lap}`}>
<line
x1={x}
x2={x}
y1={H - PB}
y2={H - PB + 4}
className="delta-graph-grid-line"
/>
<text
x={x}
y={H - PB + 16}
textAnchor="middle"
className="delta-graph-axis-label"
>
{lap}
</text>
</g>
)
})}
{drivers.map((driver) => {
const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) =>
`${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`,
)
return (
<g key={driver.label} data-testid={`delta-line-${driver.label}`}>
{segments.map((points, i) => (
<polyline
key={`${driver.label}-${i}`}
points={points}
className="delta-graph-driver-line"
stroke={driver.color}
/>
))}
</g>
)
})}
{hoverX !== null && (
<>
<line
x1={hoverX}
x2={hoverX}
y1={PT}
y2={H - PB}
className="delta-graph-crosshair"
data-testid="delta-crosshair"
/>
{tooltipRows.length > 0 && (
<g className="delta-graph-tooltip" data-testid="delta-tooltip">
<rect
x={tooltipX}
y={tooltipY}
width={tooltipW}
height={tooltipH}
rx={4}
className="delta-graph-tooltip-bg"
/>
<text
x={tooltipX + 8}
y={tooltipY + 14}
className="delta-graph-tooltip-title"
>
Lap {hoverLap! + 1}
</text>
{tooltipRows.map((row, i) => (
<text
key={row.label}
x={tooltipX + 8}
y={tooltipY + 28 + i * 14}
className="delta-graph-tooltip-row"
fill={row.color}
>
{row.label} {formatDeltaSeconds(row.delta)}
</text>
))}
</g>
)}
</>
)}
<rect
x={PL}
y={PT}
width={plotW}
height={plotH}
fill="transparent"
className="delta-graph-hover-layer"
onPointerMove={handlePointerMove}
onMouseMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onMouseLeave={handlePointerLeave}
/>
</svg>
</div>
)
}

View File

@@ -1,194 +0,0 @@
import { useMemo, useRef, useState } from 'react'
import '../../styles/telemetry-trace.css'
export interface TelemetryTraceSample {
speed: number
throttle: number
brake: number
}
export interface TelemetryTraceSeries {
label: string
color: string
samples: TelemetryTraceSample[]
}
export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake'
export interface TelemetryTraceChartProps {
series: TelemetryTraceSeries[]
channels?: TelemetryTraceChannel[]
/** Height of each channel panel in SVG units (viewBox space). */
height?: number
}
const VIEW_WIDTH = 800
const PANEL_GAP = 18
const PAD_TOP = 6
const PAD_BOTTOM = 6
const CHANNEL_LABELS: Record<TelemetryTraceChannel, string> = {
speed: 'Speed',
throttle: 'Throttle',
brake: 'Brake',
}
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number {
const raw = sample[channel]
// Throttle/brake are percentages; clamp so out-of-range API values can't
// draw outside the panel. Speed is clamped to >= 0.
if (channel === 'speed') return Math.max(0, raw)
return clamp(raw, 0, 100)
}
function formatValue(value: number, channel: TelemetryTraceChannel): string {
if (channel === 'speed') return `${Math.round(value)} km/h`
return `${Math.round(value)}%`
}
export function TelemetryTraceChart({
series,
channels = ['speed', 'throttle', 'brake'],
height = 110,
}: TelemetryTraceChartProps) {
const svgRef = useRef<SVGSVGElement | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
// Series are index-aligned; mismatched lengths are clamped to the shortest
// series so every drawn x has a value for every driver.
const sampleCount = useMemo(
() => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))),
[series],
)
const speedMax = useMemo(() => {
let max = 0
for (const s of series) {
for (let i = 0; i < sampleCount; i++) {
max = Math.max(max, channelValue(s.samples[i], 'speed'))
}
}
return max > 0 ? max : 1
}, [series, sampleCount])
if (series.length === 0 || sampleCount === 0 || channels.length === 0) {
return <div className="telemetry-trace-empty">No telemetry data</div>
}
// Clamp a stale hover index in case the series prop shrank between renders.
const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1)
const panelHeight = height
const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP
const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH
const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100)
const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => {
const usable = panelHeight - PAD_TOP - PAD_BOTTOM
const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel)
return panelTop + PAD_TOP + (1 - frac) * usable
}
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = svgRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) {
setHoverIndex(0)
return
}
const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1)
setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1))
}
return (
<div className="telemetry-trace" data-testid="telemetry-trace">
<div className="telemetry-trace-legend">
{series.map((s) => (
<span key={s.label} className="telemetry-trace-legend-item">
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
{s.label}
</span>
))}
</div>
<svg
ref={svgRef}
className="telemetry-trace-svg"
viewBox={`0 0 ${VIEW_WIDTH} ${totalHeight}`}
role="img"
aria-label="Telemetry trace chart"
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverIndex(null)}
>
{channels.map((channel, panelIdx) => {
const panelTop = panelIdx * (panelHeight + PANEL_GAP)
return (
<g key={channel} className="telemetry-trace-panel" data-channel={channel}>
<rect
className="telemetry-trace-panel-bg"
x={0}
y={panelTop}
width={VIEW_WIDTH}
height={panelHeight}
/>
<text className="telemetry-trace-panel-title" x={6} y={panelTop + 13}>
{CHANNEL_LABELS[channel]}
</text>
<text className="telemetry-trace-axis-max" x={VIEW_WIDTH - 6} y={panelTop + 13} textAnchor="end">
{channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'}
</text>
{series.map((s) => {
const points = Array.from({ length: sampleCount }, (_, i) => {
const x = xAt(i)
const y = yAt(s.samples[i][channel], channel, panelTop)
return `${x.toFixed(2)},${y.toFixed(2)}`
}).join(' ')
return (
<polyline
key={s.label}
className="telemetry-trace-line"
data-channel={channel}
data-series={s.label}
points={points}
fill="none"
stroke={s.color}
strokeWidth={1.6}
strokeLinejoin="round"
strokeLinecap="round"
/>
)
})}
</g>
)
})}
{hover !== null && (
<line
className="telemetry-trace-crosshair"
data-testid="telemetry-trace-crosshair"
x1={xAt(hover)}
y1={0}
x2={xAt(hover)}
y2={totalHeight}
/>
)}
</svg>
{hover !== null && (
<div className="telemetry-trace-readout" data-testid="telemetry-trace-readout">
<span className="telemetry-trace-readout-index">Sample {hover}</span>
{series.map((s) => (
<span key={s.label} className="telemetry-trace-readout-driver">
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
<span className="telemetry-trace-readout-label">{s.label}</span>
{channels.map((channel) => (
<span key={channel} className="telemetry-trace-readout-value">
{formatValue(channelValue(s.samples[hover], channel), channel)}
</span>
))}
</span>
))}
</div>
)}
</div>
)
}

View File

@@ -1,207 +0,0 @@
import { compoundClass } from '../../lib/live'
import '../../styles/stint-timeline.css'
export interface StintTimelineStint {
compound: string
lapStart: number
lapEnd: number
isNew?: boolean
}
export interface StintTimelineRow {
label: string
color: string
stints: StintTimelineStint[]
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
pitStops?: number[]
}
interface TyreStintTimelineProps {
rows: StintTimelineRow[]
totalLaps: number
}
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const AXIS_H = 20
const BAR_W = SVG_W - LEFT - RIGHT
const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const
function compoundLabel(compound: string): string {
const upper = compound.toUpperCase()
if (upper === 'INTERMEDIATE') return 'Intermediate'
return upper.charAt(0) + upper.slice(1).toLowerCase()
}
function stintLength(stint: StintTimelineStint): number {
return stint.lapEnd - stint.lapStart + 1
}
function stintTitle(stint: StintTimelineStint): string {
const length = stintLength(stint)
return `${compoundLabel(stint.compound)} · L${stint.lapStart}${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}`
}
function lapX(lap: number, totalLaps: number): number {
return LEFT + (lap / totalLaps) * BAR_W
}
function stintBarX(stint: StintTimelineStint, totalLaps: number): number {
return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W
}
function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
}
function pitMarkerX(lapNumber: number, totalLaps: number): number {
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
}
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
return `${driverLabel} pit stop · L${lapNumber}`
}
function axisTicks(totalLaps: number): number[] {
const ticks: number[] = []
for (let lap = 0; lap <= totalLaps; lap += 10) {
ticks.push(lap)
}
return ticks
}
function collectUsedCompounds(rows: StintTimelineRow[]): string[] {
const seen = new Set<string>()
for (const row of rows) {
for (const stint of row.stints) {
seen.add(stint.compound.toUpperCase())
}
}
const ordered = COMPOUND_ORDER.filter((c) => seen.has(c))
const extras = [...seen]
.filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number]))
.sort()
return [...ordered, ...extras]
}
export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
const safeTotal = Math.max(totalLaps, 1)
const usedCompounds = collectUsedCompounds(rows)
const ticks = axisTicks(safeTotal)
const chartH = rows.length * ROW_H
const svgH = chartH + AXIS_H + 4
if (rows.length === 0) {
return (
<div className="stint-timeline" data-testid="stint-timeline-empty">
<div className="stint-timeline__empty">No stint data to display.</div>
</div>
)
}
return (
<div className="stint-timeline" data-testid="stint-timeline">
<div className="stint-timeline__scroll">
<svg
viewBox={`0 0 ${SVG_W} ${svgH}`}
className="stint-timeline__svg"
role="img"
aria-label="Tyre stint timeline"
>
{rows.map((row, i) => {
const rowY = i * ROW_H
return (
<g key={`${row.label}-${i}`} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={row.color}
className="stint-timeline__label"
>
{row.label}
</text>
{row.stints.map((stint, si) => (
<rect
key={si}
x={stintBarX(stint, safeTotal)}
y={BAR_Y}
width={stintBarW(stint, safeTotal)}
height={BAR_H}
rx={3}
className={`stint-timeline__bar ${compoundClass(stint.compound)}${stint.isNew ? ' stint-timeline__bar--new' : ''}`}
>
<title>{stintTitle(stint)}</title>
</rect>
))}
{(row.pitStops ?? []).map((lapNumber, pi) => (
<line
key={`pit-${pi}`}
x1={pitMarkerX(lapNumber, safeTotal)}
x2={pitMarkerX(lapNumber, safeTotal)}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
className="stint-timeline__pit-marker"
data-testid="pit-marker"
data-lap={lapNumber}
>
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
</line>
))}
</g>
)
})}
<g transform={`translate(0,${chartH})`}>
<line
x1={LEFT}
x2={LEFT + BAR_W}
y1={0}
y2={0}
className="stint-timeline__axis-line"
/>
{ticks.map((lap) => (
<g key={lap}>
<line
x1={lapX(lap, safeTotal)}
x2={lapX(lap, safeTotal)}
y1={0}
y2={4}
className="stint-timeline__axis-line"
/>
<text
x={lapX(lap, safeTotal)}
y={14}
textAnchor="middle"
className="stint-timeline__axis-tick"
>
{lap}
</text>
</g>
))}
</g>
</svg>
</div>
<div className="stint-timeline__legend">
{usedCompounds.map((compound) => (
<span key={compound} className="stint-timeline__legend-item">
<span
className={`stint-timeline__legend-swatch ${compoundClass(compound)}`}
data-testid={`legend-${compound.toLowerCase()}`}
/>
{compoundLabel(compound)}
</span>
))}
<span className="stint-timeline__meta">{safeTotal} laps</span>
</div>
</div>
)
}

View File

@@ -1,72 +0,0 @@
import { useAutoAnimate } from '@formkit/auto-animate/react'
import {
Flag,
LogOut,
Megaphone,
OctagonX,
Swords,
Timer,
TrendingDown,
TrendingUp,
Wrench,
Zap,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { LiveDriverInfo } from '../../types'
import type { LiveEvent, LiveEventKind } from '../../lib/events'
import { teamColor } from '../../utils'
import '../../styles/event-rail.css'
interface Props {
events: LiveEvent[]
driverInfo?: Record<string, LiveDriverInfo>
}
const KIND_ICONS: Record<LiveEventKind, LucideIcon> = {
overtake: Swords,
'position-gain': TrendingUp,
'position-loss': TrendingDown,
'pit-in': Wrench,
'pit-out': LogOut,
'personal-best': Timer,
'fastest-lap': Zap,
retirement: OctagonX,
'track-status': Flag,
'race-control': Megaphone,
}
export function EventRail({ events, driverInfo }: Props) {
const [listRef] = useAutoAnimate<HTMLDivElement>()
return (
<section className="event-rail panel-glass" data-testid="event-rail">
<div className="sec-header sticky-header">
<span className="sec-title">What Just Happened</span>
{events.length > 0 && <span className="sec-meta">{events.length} events</span>}
</div>
{events.length === 0 ? (
<div className="missing-notice">Events will appear here as the session unfolds.</div>
) : (
<div className="event-rail-list" ref={listRef}>
{events.map((event) => {
const Icon = KIND_ICONS[event.kind] ?? Megaphone
const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour
const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)'
return (
<div className={`event-rail-row event-kind-${event.kind}`} key={event.id} style={{ borderLeftColor: accent }}>
<span className="event-rail-icon" aria-hidden="true">
<Icon size={14} />
</span>
<div className="event-rail-body">
<span className="event-rail-headline">{event.headline}</span>
{event.detail && <span className="event-rail-detail">{event.detail}</span>}
</div>
{event.lap > 0 && <span className="event-rail-lap">L{event.lap}</span>}
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -5,20 +5,18 @@ import { WeatherStrip } from './WeatherStrip'
interface Props {
isLive: boolean
isArchive?: boolean
snapshot: LiveStreamData
rows: LiveTimingRow[]
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
now: number
}
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) {
const session = snapshot.Session
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
const display = liveSessionDisplay(session, rows)
const atRiskLabel =
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
return (
<section className="live-banner">
@@ -41,7 +39,7 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
<span>
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
</span>
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
</div>
</div>
</div>

View File

@@ -1,108 +0,0 @@
import { useMemo, useRef, useState } from 'react'
import { Pause, Play } from 'lucide-react'
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
import { teamColor } from '../../utils'
import '../../styles/team-radio.css'
interface Props {
captures?: LiveRadioCapture[]
driverInfo?: Record<string, LiveDriverInfo>
session?: LiveSessionMeta
}
function relativeTime(utc: string): string {
const timestamp = new Date(utc).getTime()
if (!utc || Number.isNaN(timestamp)) return '--'
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
if (diffSeconds < 60) return `${diffSeconds}s ago`
const minutes = Math.floor(diffSeconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
return info?.Tla || racingNumber
}
export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingKey, setPlayingKey] = useState<string | null>(null)
const newestFirst = useMemo(() => [...captures].reverse(), [captures])
const toggleCapture = (capture: LiveRadioCapture) => {
const key = radioCaptureKey(capture)
const audio = audioRef.current
const url = radioClipUrl(session, capture)
if (!audio || !url) return
if (playingKey === key) {
audio.pause()
setPlayingKey(null)
return
}
audio.pause()
audio.src = url
setPlayingKey(key)
const play = audio.play()
if (play) {
play.catch(() => setPlayingKey(null))
}
}
return (
<section className="team-radio panel-glass" data-testid="team-radio-ticker">
<div className="sec-header sticky-header">
<span className="sec-title">Team Radio</span>
{captures.length > 0 && <span className="sec-meta">{captures.length} clips</span>}
</div>
<audio
ref={audioRef}
className="team-radio-audio"
onEnded={() => setPlayingKey(null)}
/>
{newestFirst.length === 0 ? (
<div className="missing-notice">No team radio clips in the current live snapshot.</div>
) : (
<div className="team-radio-list">
{newestFirst.map((capture) => {
const key = radioCaptureKey(capture)
const info = driverInfo[capture.RacingNumber]
const label = driverLabel(info, capture.RacingNumber)
const isPlaying = playingKey === key
const url = radioClipUrl(session, capture)
return (
<div className="team-radio-row" key={key}>
<button
className="team-radio-play"
type="button"
aria-label={`${isPlaying ? 'Pause' : 'Play'} ${label} radio`}
disabled={!url}
onClick={() => toggleCapture(capture)}
>
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<span
className="team-radio-driver"
style={{ borderColor: teamColor(info?.TeamColour) }}
title={info?.TeamName || undefined}
>
{label}
</span>
<span className="team-radio-time">{relativeTime(capture.Utc)}</span>
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -12,9 +12,6 @@ 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'
@@ -103,11 +100,7 @@ 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 '-'
@@ -156,14 +149,7 @@ export function TimingTower({
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
{driver.LastLapTime || '-'}
</td>
<td className="mono">
<Meaning
value={gapText || '-'}
meaning={intervalAnnotation?.caption}
title={intervalAnnotation?.title}
tone={intervalAnnotation?.tone}
/>
</td>
<td className="mono">{gapText || '-'}</td>
{isRace && (
<td className="spark-cell">

View File

@@ -1,140 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { teamColor } from '../../utils'
import type { LiveTimingRow } from '../../lib/live'
import { compoundClass, driverCode, tyreLabel } from '../../lib/live'
import { sparklinePoints } from '../../lib/gapHistory'
import { isRaceSession } from '../../lib/battles'
import type { StintHistoryMap } from '../../lib/tyredeg'
import {
PIT_LOSS_SECONDS,
degradationModel,
estimatePitRejoin,
formatSlope,
recordStintSamples,
stintInputFromRow,
} from '../../lib/tyredeg'
import { tyreAgeMeaning } from '../../lib/meaning'
import { Meaning } from '../Meaning'
import '../../styles/tyredeg.css'
const TOP_DRIVER_COUNT = 10
const SPARK_WIDTH = 64
const SPARK_HEIGHT = 16
interface Props {
rows: LiveTimingRow[]
sessionType: string | undefined
pinned: string[]
}
function StintSparkline({ seconds }: { seconds: number[] }) {
if (seconds.length < 2) return <span className="tyredeg-spark-empty">·</span>
const points = sparklinePoints(seconds, SPARK_WIDTH, SPARK_HEIGHT)
return (
<svg
className="tyredeg-spark"
width={SPARK_WIDTH}
height={SPARK_HEIGHT}
viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
aria-hidden="true"
focusable="false"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
)
}
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [stints, setStints] = useState<StintHistoryMap>({})
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
useEffect(() => {
if (rows.length === 0) return
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
}, [rows])
const isRace = isRaceSession(sessionType)
const visible = useMemo(
() =>
rows.filter(
(row) =>
row.Position > 0 &&
!row.Driver.Retired &&
(row.Position <= TOP_DRIVER_COUNT || pinned.includes(row.RacingNumber)),
),
[rows, pinned],
)
if (visible.length === 0) return null
return (
<section className="live-tyredeg-panel" data-testid="tyredeg-panel">
<button
type="button"
className="sec-header tyredeg-toggle"
onClick={() => setCollapsed((prev) => !prev)}
aria-expanded={!collapsed}
>
<span className="sec-title">Tyre Deg &amp; Pit Window</span>
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
</button>
{!collapsed && (
<div className="tyredeg-rows">
{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 (
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
<span className="tyredeg-pos mono">P{row.Position}</span>
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
<span className="drv-code">{driverCode(row)}</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 ? (
<>
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
<StintSparkline seconds={model.samples.map((sample) => sample.seconds)} />
</span>
<span className={`tyredeg-slope mono tyredeg-trend-${model.trend}`}>
{formatSlope(model.slope)}
</span>
</>
) : (
<span className="tyredeg-warmup">warming up</span>
)}
{rejoin && (
<span className="tyredeg-rejoin mono" title={`Pits now: rejoins ~P${rejoin.rejoinPosition}`}>
~P{rejoin.rejoinPosition}
{rejoin.aheadCode && rejoin.behindCode && (
<span className="tyredeg-rejoin-between">
{rejoin.aheadCode} · {rejoin.behindCode}
</span>
)}
</span>
)}
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -1,132 +0,0 @@
import type { Chapter } from '../types'
export function chapterKindLabel(kind: string): string {
switch (kind) {
case 'start':
return 'GO'
case 'safety_car':
return 'SC'
case 'virtual_safety_car':
return 'VSC'
case 'red_flag':
return 'RF'
case 'pit_phase':
return 'PIT'
case 'decisive_swing':
return '▲'
case 'finish':
return 'FIN'
default:
return kind.slice(0, 3).toUpperCase()
}
}
export function chapterLapRange(chapter: Chapter): string {
if (chapter.start_lap === chapter.end_lap) {
return `L${chapter.start_lap}`
}
return `L${chapter.start_lap}L${chapter.end_lap}`
}
/** Normalized scrub position (01) for a chapter's start time on the chart axis. */
export function chapterStartScrub(
chapter: Chapter,
tMin: number,
tRange: number,
): number | null {
if (!chapter.start_time || tRange <= 0) return null
const ms = new Date(chapter.start_time).getTime()
if (Number.isNaN(ms)) return null
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
}
/** Normalized scrub position (01) for a chapter's end time on the chart axis. */
export function chapterEndScrub(
chapter: Chapter,
tMin: number,
tRange: number,
): number | null {
const raw = chapter.end_time ?? chapter.start_time
if (!raw || tRange <= 0) return null
const ms = new Date(raw).getTime()
if (Number.isNaN(ms)) return null
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
}
/** Index of the chapter containing the current scrub position, if any.
* Uses the same 01 clamped bounds as chapterStartScrub/chapterEndScrub so
* chapters whose timestamps fall outside the position-sample window still
* activate when the scrubber is parked at the clamped edge. */
export function activeChapterIndex(
chapters: Chapter[],
scrubTime: number | null,
tMin: number,
tRange: number,
): number | null {
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
for (let i = 0; i < chapters.length; i++) {
const start = chapterStartScrub(chapters[i], tMin, tRange)
const end = chapterEndScrub(chapters[i], tMin, tRange)
if (start === null || end === null) continue
const lo = Math.min(start, end)
const hi = Math.max(start, end)
if (scrubTime >= lo && scrubTime <= hi) return i
}
return null
}
/** Duration in ms each chapter should play during a ~90s tour. */
export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
if (chapters.length === 0) return []
const perChapter = totalMs / chapters.length
return chapters.map(() => perChapter)
}
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
export function decimatedPositionLabels(maxPos: number): number[] {
if (maxPos <= 1) return [1]
const labels = new Set<number>([1])
for (let pos = 5; pos < maxPos; pos += 5) {
labels.add(pos)
}
labels.add(maxPos)
return [...labels].sort((a, b) => a - b)
}
/** Subtle background fill for chapter time-bands on the position graph. */
export function chapterBandFill(kind: string): string {
switch (kind) {
case 'safety_car':
return 'rgba(255, 153, 0, 0.14)'
case 'virtual_safety_car':
return 'rgba(255, 204, 0, 0.12)'
case 'red_flag':
return 'rgba(230, 36, 41, 0.12)'
case 'pit_phase':
return 'rgba(96, 165, 250, 0.08)'
case 'decisive_swing':
return 'rgba(34, 197, 94, 0.08)'
case 'finish':
return 'rgba(255, 204, 0, 0.06)'
default:
return 'rgba(255, 255, 255, 0.03)'
}
}
/** Minimum vertical spacing between colliding labels (SVG units). */
export function deCollideYPositions(
items: ReadonlyArray<{ key: string | number; y: number }>,
minGap = 12,
): Map<string | number, number> {
if (items.length === 0) return new Map()
const sorted = [...items].sort((a, b) => a.y - b.y)
const adjusted = sorted.map((item) => ({ ...item }))
for (let i = 1; i < adjusted.length; i++) {
const prev = adjusted[i - 1]
const curr = adjusted[i]
if (curr.y - prev.y < minGap) {
curr.y = prev.y + minGap
}
}
return new Map(adjusted.map((item) => [item.key, item.y]))
}

View File

@@ -1,202 +0,0 @@
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
import type { DeltaSeries } from '../lib/delta'
import type {
CarDataSample,
ComparisonLap,
Driver,
EnrichedResult,
LapsComparisonResponse,
} from '../types'
import { compareFinishPosition, teamColor } from '../utils'
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
const order = new Map(
[...results]
.filter((r) => r.position > 0)
.sort((a, b) => compareFinishPosition(a.position, b.position))
.map((r, i) => [r.driver_number, i]),
)
return [...drivers].sort((a, b) => {
const ao = order.get(a.driver_number) ?? 999
const bo = order.get(b.driver_number) ?? 999
if (ao !== bo) return ao - bo
return a.driver_number - b.driver_number
})
}
/** Default compare pair: top two classified finishers, else first two session drivers. */
export function defaultCompareDriverNumbers(
results: EnrichedResult[],
drivers: Driver[],
): [number, number] | null {
const classified = [...results]
.filter((r) => r.position > 0)
.sort((a, b) => compareFinishPosition(a.position, b.position))
if (classified.length >= 2) {
return [classified[0].driver_number, classified[1].driver_number]
}
if (drivers.length >= 2) {
const sorted = sortDriversByResults(drivers, results)
return [sorted[0].driver_number, sorted[1].driver_number]
}
if (classified.length === 1 && drivers.length >= 1) {
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
if (other) return [classified[0].driver_number, other.driver_number]
}
return null
}
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
if (laps.length === 0) return []
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
for (const lap of laps) {
const idx = lap.lap_number - 1
if (idx < 0) continue
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
times[idx] = null
} else {
times[idx] = lap.lap_duration
}
}
return times
}
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
let best: ComparisonLap | null = null
for (const lap of laps) {
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
best = lap
}
}
return best
}
/** Keep car-data samples whose timestamps fall within a lap window. */
export function filterCarDataToLap(
samples: CarDataSample[],
lap: ComparisonLap,
): CarDataSample[] {
if (!lap.date_start || samples.length === 0) return samples
const start = new Date(lap.date_start).getTime()
if (!Number.isFinite(start)) return samples
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
return samples.filter((s) => {
const t = new Date(s.date).getTime()
return Number.isFinite(t) && t >= start && t <= end
})
}
export function carDataToTraceSeries(
samples: CarDataSample[],
label: string,
color: string,
): TelemetryTraceSeries {
return {
label,
color,
samples: samples.map((s) => ({
speed: s.speed,
throttle: s.throttle,
brake: s.brake,
})),
}
}
export function buildBestLapTraceSeries(
carData: CarDataSample[],
laps: ComparisonLap[],
label: string,
color: string,
): TelemetryTraceSeries | null {
const best = findBestLap(laps)
if (!best) return null
const filtered = filterCarDataToLap(carData, best)
if (filtered.length === 0) return null
return carDataToTraceSeries(filtered, label, color)
}
function driverMeta(
driverNumber: number,
comparison: LapsComparisonResponse | undefined,
drivers: Driver[],
): { label: string; color: string; laps: ComparisonLap[] } {
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
const session = drivers.find((d) => d.driver_number === driverNumber)
return {
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
color: teamColor(comp?.team_colour || session?.team_colour),
laps: comp?.laps ?? [],
}
}
export function comparisonToDeltaSeries(
comparison: LapsComparisonResponse,
driverNumbers: [number, number],
drivers: Driver[],
): DeltaSeries[] {
return driverNumbers.map((dn) => {
const meta = driverMeta(dn, comparison, drivers)
return {
label: meta.label,
color: meta.color,
lapTimes: lapsToLapTimes(meta.laps),
}
})
}
export function formatPitLapsCaption(
pitLaps: Record<string, number[]>,
driverNumbers: [number, number],
comparison: LapsComparisonResponse | undefined,
drivers: Driver[],
): string | null {
const parts: string[] = []
for (const dn of driverNumbers) {
const laps = pitLaps[String(dn)]
if (!laps?.length) continue
const meta = driverMeta(dn, comparison, drivers)
parts.push(`${meta.label}: L${laps.join(', L')}`)
}
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
}
export function compareDriverOptions(
drivers: Driver[],
results: EnrichedResult[],
): Driver[] {
if (drivers.length > 0) {
return sortDriversByResults(drivers, results)
}
return results.map((r) => ({
driver_number: r.driver_number,
name_acronym: r.name_acronym,
full_name: r.full_name,
first_name: '',
last_name: '',
team_name: r.team_name,
team_colour: r.team_colour,
headshot_url: '',
broadcast_name: r.full_name,
session_key: r.session_key,
meeting_key: r.meeting_key,
}))
}

View File

@@ -1,127 +0,0 @@
// Cumulative lap-time delta math for driver comparison charts.
// Pure functions only — no React — so everything is unit-testable.
export interface DeltaSeries {
label: string
color: string
/** Lap duration in seconds; null = no time (pit/out lap). */
lapTimes: (number | null)[]
}
export interface DriverDeltaResult {
label: string
color: string
/** Cumulative delta vs reference (seconds); null when that lap has no time. */
deltas: (number | null)[]
}
/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */
export function formatDeltaSeconds(delta: number): string {
const sign = delta >= 0 ? '+' : ''
return `${sign}${delta.toFixed(1)}s`
}
/**
* Cumulative lap time aligned to reference-valid laps only.
* Laps where the reference is null are skipped for every series so later deltas
* do not compare against a frozen baseline while challengers keep accumulating.
*/
function buildAlignedCumulative(
lapTimes: ReadonlyArray<number | null>,
referenceLapTimes: ReadonlyArray<number | null>,
): number[] {
const cumulative: number[] = []
let running = 0
const length = Math.max(lapTimes.length, referenceLapTimes.length)
for (let i = 0; i < length; i++) {
if (referenceLapTimes[i] == null) {
cumulative.push(running)
continue
}
const lap = lapTimes[i]
if (lap != null) {
running += lap
}
cumulative.push(running)
}
return cumulative
}
function resolveReference(
series: ReadonlyArray<DeltaSeries>,
referenceLabel?: string,
): DeltaSeries | null {
if (series.length === 0) return null
if (referenceLabel) {
return series.find((s) => s.label === referenceLabel) ?? series[0]
}
return series[0]
}
/**
* Compute per-lap cumulative time delta for each non-reference driver.
* Positive = behind reference; negative = ahead.
* Deltas are only emitted where the reference lap is valid; reference-null laps
* gap every series. Challenger-null laps gap only that driver's line.
*/
export function computeCumulativeDeltas(
series: ReadonlyArray<DeltaSeries>,
referenceLabel?: string,
): DriverDeltaResult[] {
const reference = resolveReference(series, referenceLabel)
if (!reference) return []
const refLapTimes = reference.lapTimes
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
return series
.filter((s) => s.label !== reference.label)
.map((driver) => {
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
const deltas: (number | null)[] = []
for (let i = 0; i < lapCount; i++) {
if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
deltas.push(null)
continue
}
deltas.push(driverCumulative[i] - refCumulative[i])
}
return {
label: driver.label,
color: driver.color,
deltas,
}
})
}
/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */
export function deltaPolylineSegments(
deltas: ReadonlyArray<number | null>,
toPoint: (lapIndex: number, delta: number) => string,
): string[] {
const segments: string[] = []
let current: string[] = []
for (let i = 0; i < deltas.length; i++) {
const value = deltas[i]
if (value === null) {
if (current.length > 0) {
segments.push(current.join(' '))
current = []
}
continue
}
current.push(toPoint(i, value))
}
if (current.length > 0) {
segments.push(current.join(' '))
}
return segments
}

View File

@@ -1,293 +0,0 @@
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
export interface GPWindow {
meeting_key: number
meeting_name: string
/** Inclusive lower bound: end of previous GP, or open for season opener. */
start: Date | null
/** Exclusive upper bound: start of next GP, or open after the final round. */
end: Date | null
}
export interface DigestTag {
kind: 'driver' | 'team'
key: string
label: string
colour: string
}
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
export const RECENT_BUCKET_KEY = '__recent__'
interface MatchCandidate {
start: number
end: number
tag: DigestTag
}
interface TagPattern {
pattern: string
tag: DigestTag
}
function sortMeetings(meetings: Meeting[]): Meeting[] {
return [...meetings].sort((a, b) => {
const left = meetingStartTime(a)?.getTime() ?? 0
const right = meetingStartTime(b)?.getTime() ?? 0
if (left !== right) return left - right
return a.meeting_name.localeCompare(b.meeting_name)
})
}
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
const sorted = sortMeetings(meetings)
return sorted.map((meeting, index) => ({
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name,
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
}))
}
function itemPublishedAt(item: NewsItem): Date | null {
if (!item.published_at) return null
const parsed = Date.parse(item.published_at)
return Number.isNaN(parsed) ? null : new Date(parsed)
}
function itemInWindow(published: Date, window: GPWindow): boolean {
if (window.start && published < window.start) return false
if (window.end && published >= window.end) return false
return true
}
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
const matches = windows.filter((window) => itemInWindow(published, window))
if (matches.length === 0) return null
return matches.reduce((best, window) => {
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
return windowStart >= bestStart ? window : best
})
}
export interface GroupedDigest {
windows: Array<{ window: GPWindow; items: NewsItem[] }>
recent: NewsItem[]
}
/** Bucket news items into GP windows; undated items land in `recent`. */
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
const buckets = new Map<number, NewsItem[]>()
const recent: NewsItem[] = []
for (const item of items) {
const published = itemPublishedAt(item)
if (!published) {
recent.push(item)
continue
}
const match = windowForDate(windows, published)
if (!match) {
recent.push(item)
continue
}
const list = buckets.get(match.meeting_key) ?? []
list.push(item)
buckets.set(match.meeting_key, list)
}
const grouped = windows
.map((window) => ({
window,
items: buckets.get(window.meeting_key) ?? [],
}))
.filter((entry) => entry.items.length > 0)
return { windows: grouped, recent }
}
function driverLastName(fullName: string): string | null {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length < 2) return null
return parts[parts.length - 1] ?? null
}
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
const patterns: TagPattern[] = []
for (const team of teams) {
if (!team.team_name.trim()) continue
patterns.push({
pattern: team.team_name.toLowerCase(),
tag: {
kind: 'team',
key: `team:${team.team_name}`,
label: team.team_name,
colour: team.team_colour,
},
})
}
for (const driver of drivers) {
const colour = driver.team_colour
const base = {
kind: 'driver' as const,
colour,
}
if (driver.full_name.trim()) {
patterns.push({
pattern: driver.full_name.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
const lastName = driverLastName(driver.full_name)
if (lastName) {
patterns.push({
pattern: lastName.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
}
}
if (driver.name_acronym.trim()) {
patterns.push({
pattern: driver.name_acronym.toLowerCase(),
tag: {
...base,
key: `driver:${driver.name_acronym}`,
label: driver.name_acronym,
},
})
}
}
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
return patterns
}
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Tag items via case-insensitive substring match on title + summary.
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
*
* Known limitations: common-word last names, team names inside other words,
* and driver TLAs that appear in unrelated acronyms can false-positive.
*/
export function tagItems(
items: NewsItem[],
drivers: ChampHubDriver[],
teams: ChampHubTeam[],
): TaggedNewsItem[] {
const patterns = buildTagPatterns(drivers, teams)
return items.map((item) => {
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
const accepted: MatchCandidate[] = []
for (const { pattern, tag } of patterns) {
if (!pattern) continue
let from = 0
while (from <= haystack.length - pattern.length) {
const index = haystack.indexOf(pattern, from)
if (index === -1) break
const candidate: MatchCandidate = {
start: index,
end: index + pattern.length,
tag,
}
if (!accepted.some((match) => overlaps(match, candidate))) {
accepted.push(candidate)
}
from = index + 1
}
}
const tagsByKey = new Map<string, DigestTag>()
for (const match of accepted) {
tagsByKey.set(match.tag.key, match.tag)
}
return {
...item,
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
}
})
}
export function activeDigestWindow(
windows: GPWindow[],
meetings: Meeting[],
now: Date,
): GPWindow | null {
const next = nextUpcomingMeeting(meetings, now)
if (next) {
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
}
const recent = mostRecentPastMeeting(meetings, now)
if (recent) {
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
}
return windows[0] ?? null
}
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
const last = mostRecentPastMeeting(meetings, now)
if (last) return last.meeting_name
const next = nextUpcomingMeeting(meetings, now)
if (next) return `Before ${next.meeting_name}`
return 'Pre-season'
}
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
if (!window) return []
return items.filter((item) => {
const published = itemPublishedAt(item)
return published != null && itemInWindow(published, window)
})
}
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
const counts = new Map<string, { tag: DigestTag; count: number }>()
for (const item of items) {
for (const tag of item.tags) {
const current = counts.get(tag.key)
if (current) current.count += 1
else counts.set(tag.key, { tag, count: 1 })
}
}
return [...counts.values()]
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
.slice(0, limit)
.map((entry) => entry.tag)
}
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
if (!tagKey) return items
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
}
export function tagColour(colour: string): string {
if (!colour) return 'var(--text-3)'
return colour.startsWith('#') ? colour : `#${colour}`
}
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
return [...buckets].sort((a, b) => {
const left = a.window.start?.getTime() ?? 0
const right = b.window.start?.getTime() ?? 0
return right - left
})
}

View File

@@ -1,69 +0,0 @@
import type { DriverSummaryRound } from '../types'
/** One round mapped for the grid-vs-finish chart and results table. */
export interface RoundDelta {
round: number // 1-based round index within the completed season
label: string // short GP label, e.g. "Bahrain"
grid: number | null
finish: number | null
/** grid finish: positive = places gained on Sunday, null when unknowable. */
delta: number | null
points: number
status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
}
/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
export function shortGpLabel(meetingName: string): string {
const short = meetingName
.replace(/\s*grand prix\s*/i, ' ')
.replace(/\s+gp\s*$/i, ' ')
.trim()
return short || meetingName
}
function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
if (r.dns) return 'dns'
if (r.dsq) return 'dsq'
if (r.dnf) return 'dnf'
if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
return 'classified'
}
/**
* Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
* (driver absent, pit-lane start, …) and become null; a delta is only computed
* when both ends are known and the driver actually started the race.
*/
export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
return rounds.map((r, i) => {
const status = roundStatus(r)
const grid = r.grid_position > 0 ? r.grid_position : null
const finish = r.race_position > 0 ? r.race_position : null
const delta =
grid != null && finish != null && status !== 'dns' && status !== 'absent'
? grid - finish
: null
return {
round: i + 1,
label: shortGpLabel(r.meeting_name),
grid,
finish,
delta,
points: r.points,
status,
}
})
}
/** 'P4' for known positions, '—' when unknown. */
export function formatPosition(pos: number | null): string {
return pos != null && pos > 0 ? `P${pos}` : '—'
}
/** '+3' (gained), '2' (lost), '=' (held), '—' (unknown). */
export function formatDelta(delta: number | null): string {
if (delta == null) return '—'
if (delta > 0) return `+${delta}`
if (delta < 0) return `${Math.abs(delta)}`
return '='
}

View File

@@ -1,318 +0,0 @@
// "What just happened" event synthesis for the live page.
// Pure functions only — no React, no side effects — so everything is unit-testable.
// Mirrors the gapHistory/battles precedent: successive LiveStreamData snapshots
// in, typed events out; the page owns the buffer, this module owns the diffing.
import type {
LiveDriverData,
LiveRCMessage,
LiveSessionMeta,
LiveStreamData,
} from '../types'
export const MAX_EVENTS = 30
export type LiveEventKind =
| 'overtake'
| 'position-gain'
| 'position-loss'
| 'pit-in'
| 'pit-out'
| 'personal-best'
| 'fastest-lap'
| 'retirement'
| 'track-status'
| 'race-control'
export interface LiveEvent {
id: string
kind: LiveEventKind
lap: number
timestamp: string
headline: string
detail?: string
racingNumbers: string[]
}
/**
* Identity of the running session, used to reset the event buffer when a new
* session starts. LiveSessionMeta carries no numeric session key, so the
* meeting + session name/type triple stands in for one.
*/
export function sessionSignature(session: LiveSessionMeta | null | undefined): string {
if (!session) return ''
return [session.MeetingName, session.SessionName, session.SessionType].join('|')
}
/**
* Prepend freshly diffed events onto the newest-first buffer, dropping any
* whose id is already present (re-renders and replayed diffs must not
* duplicate) and evicting the oldest events beyond `cap`.
*/
export function appendEvents(
buffer: ReadonlyArray<LiveEvent>,
incoming: ReadonlyArray<LiveEvent>,
cap = MAX_EVENTS,
): LiveEvent[] {
const seen = new Set(buffer.map((event) => event.id))
const fresh = incoming.filter((event) => {
if (seen.has(event.id)) return false
seen.add(event.id)
return true
})
if (fresh.length === 0) return [...buffer]
// Within one diff the array is oldest-first; reverse so the last-produced
// event ends up at the top of the newest-first rail.
return [...fresh.reverse(), ...buffer].slice(0, cap)
}
const TRACK_STATUS_HEADLINES: Record<string, string> = {
'1': 'Green flag — track clear',
'2': 'Yellow flag',
'4': 'Safety Car deployed',
'5': 'Red flag — session stopped',
'6': 'Virtual Safety Car deployed',
'7': 'Virtual Safety Car ending',
}
const RC_CATEGORIES = new Set(['flag', 'safetycar', 'drs'])
/**
* Diff two successive live snapshots into a chronological list of synthesized
* events. Callers are expected to feed genuinely consecutive snapshots of the
* same session; diffing across a session boundary is the caller's job to
* prevent (see sessionSignature).
*/
export function diffSnapshots(prev: LiveStreamData, next: LiveStreamData): LiveEvent[] {
if (!prev || !next || prev === next) return []
const events: LiveEvent[] = []
const lap = next.CurrentLap || 0
const timestamp = next.Clock || ''
const code = (racingNumber: string): string =>
next.DriverInfo?.[racingNumber]?.Tla ||
prev.DriverInfo?.[racingNumber]?.Tla ||
`#${racingNumber}`
diffTrackStatus(prev, next, lap, timestamp, events)
diffRaceControl(prev, next, events)
const prevDrivers = prev.Drivers ?? {}
const nextDrivers = next.Drivers ?? {}
const numbers = Object.keys(nextDrivers).filter((number) => prevDrivers[number])
// Racing numbers whose position loss is already explained by an overtake
// event, so we don't also report a generic "drops to Pn".
const overtakenNumbers = new Set<string>()
for (const number of numbers) {
const before = prevDrivers[number]
const after = nextDrivers[number]
if (!before.Retired && after.Retired) {
events.push({
id: `ret:${number}`,
kind: 'retirement',
lap,
timestamp,
headline: `${code(number)} retires`,
detail: before.Position > 0 ? `Was running P${before.Position}` : undefined,
racingNumbers: [number],
})
}
if (!before.InPit && after.InPit && !after.Retired) {
const fromPosition = before.Position > 0 ? ` from P${before.Position}` : ''
events.push({
id: `pit-in:${number}:l${lap}`,
kind: 'pit-in',
lap,
timestamp,
headline: `${code(number)} pits${fromPosition}`,
racingNumbers: [number],
})
}
if (before.InPit && !after.InPit && !after.Retired) {
const compound = next.Tyres?.[number]?.Compound || ''
const stops = Math.max(0, (next.Stints?.[number]?.length ?? 0) - 1)
const parts = [compound, stops > 0 ? `${ordinal(stops)} stop` : ''].filter(Boolean)
const rejoined = after.Position > 0 ? ` P${after.Position}` : ''
events.push({
id: `pit-out:${number}:l${lap}`,
kind: 'pit-out',
lap,
timestamp,
headline: `${code(number)} rejoins${rejoined}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`,
racingNumbers: [number],
})
}
diffBestLap(number, before, after, code, lap, timestamp, events)
}
// Position gains, with overtake attribution via position swaps.
for (const number of numbers) {
const before = prevDrivers[number]
const after = nextDrivers[number]
if (before.Position <= 0 || after.Position <= 0) continue
if (after.Position >= before.Position) continue
if (after.InPit || after.Retired) continue
const overtaken = numbers.find((other) => {
if (other === number) return false
const otherBefore = prevDrivers[other]
const otherAfter = nextDrivers[other]
return (
otherBefore.Position === after.Position &&
otherAfter.Position > otherBefore.Position &&
!otherAfter.InPit &&
!otherAfter.Retired
)
})
if (overtaken) {
overtakenNumbers.add(overtaken)
events.push({
id: `overtake:${number}:${overtaken}:p${after.Position}:l${lap}`,
kind: 'overtake',
lap,
timestamp,
headline: `${code(number)} overtakes ${code(overtaken)} for P${after.Position}`,
detail: before.Position - after.Position > 1 ? `Up from P${before.Position}` : undefined,
racingNumbers: [number, overtaken],
})
} else {
events.push({
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
kind: 'position-gain',
lap,
timestamp,
headline: `${code(number)} moves up to P${after.Position} (from P${before.Position})`,
racingNumbers: [number],
})
}
}
// Position losses not already explained by an overtake or a pit visit.
for (const number of numbers) {
const before = prevDrivers[number]
const after = nextDrivers[number]
if (before.Position <= 0 || after.Position <= 0) continue
if (after.Position <= before.Position) continue
if (overtakenNumbers.has(number)) continue
if (after.InPit || after.PitOut || after.Retired || before.InPit) continue
events.push({
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
kind: 'position-loss',
lap,
timestamp,
headline: `${code(number)} drops to P${after.Position} (from P${before.Position})`,
racingNumbers: [number],
})
}
return events
}
function diffTrackStatus(
prev: LiveStreamData,
next: LiveStreamData,
lap: number,
timestamp: string,
events: LiveEvent[],
): void {
if (!prev.TrackStatus || !next.TrackStatus) return
if (prev.TrackStatus === next.TrackStatus) return
events.push({
id: `track:${next.TrackStatus}:l${lap}`,
kind: 'track-status',
lap,
timestamp,
headline: TRACK_STATUS_HEADLINES[next.TrackStatus] ?? `Track status ${next.TrackStatus}`,
racingNumbers: [],
})
}
function diffRaceControl(prev: LiveStreamData, next: LiveStreamData, events: LiveEvent[]): void {
const prevMessages = prev.RCMessages ?? []
const seen = new Set(prevMessages.map(rcMessageKey))
for (const message of next.RCMessages ?? []) {
if (!message.Message || seen.has(rcMessageKey(message))) continue
if (!RC_CATEGORIES.has((message.Category || '').toLowerCase())) continue
events.push({
id: `rc:${rcMessageKey(message)}`,
kind: 'race-control',
lap: message.Lap || 0,
timestamp: message.Time || '',
headline: humanizeRaceControl(message.Message),
racingNumbers: carNumbersIn(message.Message),
})
}
}
function diffBestLap(
number: string,
before: LiveDriverData,
after: LiveDriverData,
code: (racingNumber: string) => string,
lap: number,
timestamp: string,
events: LiveEvent[],
): void {
if (!after.LastLapTime) return
const isNewLap = after.LastLapTime !== before.LastLapTime
if (after.LastLapOB && (isNewLap || !before.LastLapOB)) {
events.push({
id: `ob:${number}:${after.LastLapTime}`,
kind: 'fastest-lap',
lap,
timestamp,
headline: `${code(number)} sets the fastest lap — ${after.LastLapTime}`,
racingNumbers: [number],
})
return
}
if (after.LastLapPB && !after.LastLapOB && (isNewLap || !before.LastLapPB)) {
events.push({
id: `pb:${number}:${after.LastLapTime}`,
kind: 'personal-best',
lap,
timestamp,
headline: `${code(number)} sets a personal best — ${after.LastLapTime}`,
racingNumbers: [number],
})
}
}
function rcMessageKey(message: LiveRCMessage): string {
return `${message.Time}:${message.Message}`
}
/** "SAFETY CAR DEPLOYED" -> "Safety car deployed", keeping known acronyms upper-cased. */
export function humanizeRaceControl(message: string): string {
const lower = message.trim().toLowerCase()
if (!lower) return ''
const sentence = lower.charAt(0).toUpperCase() + lower.slice(1)
return sentence.replace(/\b(drs|vsc|sc|fia)\b/gi, (acronym) => acronym.toUpperCase())
}
function carNumbersIn(message: string): string[] {
const numbers: string[] = []
for (const match of message.matchAll(/CAR (\d+)/gi)) {
if (!numbers.includes(match[1])) numbers.push(match[1])
}
return numbers
}
function ordinal(n: number): string {
const rem10 = n % 10
const rem100 = n % 100
if (rem10 === 1 && rem100 !== 11) return `${n}st`
if (rem10 === 2 && rem100 !== 12) return `${n}nd`
if (rem10 === 3 && rem100 !== 13) return `${n}rd`
return `${n}th`
}

View File

@@ -47,9 +47,7 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
return name.slice(0, 3).toUpperCase() || '—'
}
export function countryFlag(
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
): string {
export function countryFlag(meeting: Meeting | undefined | null): string {
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
const code = meeting?.country_code?.toUpperCase()
const iso2 = code ? CODE3_TO_2[code] : ''

View File

@@ -1,47 +0,0 @@
import type { ChampHubDriver } from '../types'
export interface TeammatePair {
teamName: string
teamColour: string
driverA: ChampHubDriver
driverB: ChampHubDriver
/** Drivers on the team beyond the top two (shown as +N). */
extraCount: number
/** Absolute win gap — smaller means a closer battle. */
closeness: number
}
/** Pair teammates per team for H2H display; skip single-driver teams. */
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
const byTeam = new Map<string, ChampHubDriver[]>()
for (const d of drivers) {
const list = byTeam.get(d.team_name) ?? []
list.push(d)
byTeam.set(d.team_name, list)
}
const pairs: TeammatePair[] = []
for (const [teamName, members] of byTeam) {
if (members.length < 2) continue
const sorted = [...members].sort((a, b) => b.points - a.points)
const driverA = sorted[0]
const driverB = sorted[1]
const winsA = driverA.teammate_wins
const winsB = driverB.teammate_wins
pairs.push({
teamName,
teamColour: driverA.team_colour,
driverA,
driverB,
extraCount: members.length - 2,
closeness: Math.abs(winsA - winsB),
})
}
return pairs.sort((a, b) => {
if (a.closeness !== b.closeness) return a.closeness - b.closeness
return a.teamName.localeCompare(b.teamName)
})
}

View File

@@ -1,27 +0,0 @@
import type { Session } from '../types'
import { sessionEndTime, sessionStartTime, type FocusMeetingKind } from './schedule'
export type HeroStateKind = 'live' | 'upcoming' | 'between'
export interface HeroStateInputs {
now: Date
liveActive: boolean
currentSession: Session | null
focusKind: FocusMeetingKind | null
}
export function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
const start = sessionStartTime(session)
const end = sessionEndTime(session)
if (start && end && now >= start && now < end) return 'live'
if (start && now >= start) return 'done'
return 'upcoming'
}
export function heroState(inputs: HeroStateInputs): HeroStateKind {
const { liveActive, currentSession, focusKind } = inputs
if (liveActive || currentSession) return 'live'
if (focusKind === 'current') return 'upcoming'
return 'between'
}

View File

@@ -1,146 +0,0 @@
// 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

@@ -1,123 +0,0 @@
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
import {
currentAndNextSession,
nextUpcomingMeeting,
sessionStartTime,
sortSessionsByStart,
} from './schedule'
export const RACE_WIN_POINTS = 25
export const SPRINT_WIN_POINTS = 8
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
const active = meetings.filter((m) => !m.is_cancelled)
return nextUpcomingMeeting(active, now)
}
export function findPriorYearMeetingByCircuit(
priorYearMeetings: Meeting[],
circuitKey: number | undefined,
): Meeting | null {
if (!circuitKey) return null
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
}
export function findRaceSession(sessions: Session[]): Session | null {
const sorted = sortSessionsByStart(sessions)
return (
sorted.find((s) => {
const type = s.session_type?.toLowerCase() ?? ''
return type.includes('race') && !type.includes('sprint')
}) ?? null
)
}
export function isSprintWeekend(sessions: Session[]): boolean {
return sessions.some((s) => {
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
})
}
export interface PodiumPlace {
position: number
name_acronym: string
full_name: string
team_name: string
team_colour: string
}
export interface PolePosition {
name_acronym: string
full_name: string
team_name: string
team_colour: string
}
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
return results
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
.sort((a, b) => a.position - b.position)
.map((r) => ({
position: r.position,
name_acronym: r.name_acronym,
full_name: r.full_name,
team_name: r.team_name,
team_colour: r.team_colour,
}))
}
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
const pole = grid.find((g) => g.position === 1)
if (!pole) return null
return {
name_acronym: pole.name_acronym,
full_name: pole.full_name,
team_name: pole.team_name,
team_colour: pole.team_colour,
}
}
export interface TitleFightDriver {
name_acronym: string
full_name: string
team_colour: string
points: number
position: number
gapToLeader: number
gapAfterRaceWin: number
gapAfterSprintWeekendMax: number
}
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
if (hub.drivers.length === 0) return []
const leader = hub.drivers[0]
return hub.drivers.slice(0, 3).map((d) => ({
name_acronym: d.name_acronym,
full_name: d.full_name,
team_colour: d.team_colour,
points: d.points,
position: d.position,
gapToLeader: leader.points - d.points,
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
}))
}
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
const sorted = sortSessionsByStart(sessions)
const { next } = currentAndNextSession(sorted, now)
if (next) return next
for (const session of sorted) {
const start = sessionStartTime(session)
if (start && start > now) return session
}
return null
}
export function formatPointsGap(gap: number): string {
if (gap === 0) return 'LEADER'
const sign = gap > 0 ? '+' : ''
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
}

View File

@@ -1,22 +0,0 @@
import type { LiveRadioCapture, LiveSessionMeta } from '../types'
export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/'
export function radioClipUrl(
session: Pick<LiveSessionMeta, 'Path'> | null | undefined,
capture: Pick<LiveRadioCapture, 'Path'> | null | undefined,
): string {
const sessionPath = session?.Path?.trim()
const capturePath = capture?.Path?.trim()
if (!sessionPath || !capturePath) return ''
if (/^https?:\/\//i.test(capturePath)) return capturePath
const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '')
const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '')
const normalizedCapture = capturePath.replace(/^\/+/g, '')
return `${base}/${normalizedSession}/${normalizedCapture}`
}
export function radioCaptureKey(capture: LiveRadioCapture): string {
return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}`
}

View File

@@ -1,75 +0,0 @@
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
import { canvasToSvg, normalizeRawPoint } from './trackmap'
export interface ReplayFramePair {
previous: ReplayFrame | null
next: ReplayFrame | null
}
export function lookupReplayFramePair(
frames: ReadonlyArray<ReplayFrame>,
tMs: number,
): ReplayFramePair {
if (frames.length === 0) return { previous: null, next: null }
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
const last = frames[frames.length - 1]
if (tMs >= last.t) return { previous: last, next: last }
let lo = 0
let hi = frames.length - 1
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const frame = frames[mid]
if (frame.t === tMs) return { previous: frame, next: frame }
if (frame.t < tMs) {
lo = mid + 1
} else {
hi = mid - 1
}
}
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
}
export function interpolateReplayCars(
frames: ReadonlyArray<ReplayFrame>,
tMs: number,
): Record<string, ReplayCarPosition> {
const { previous, next } = lookupReplayFramePair(frames, tMs)
if (!previous && !next) return {}
if (!previous) return next?.cars ?? {}
if (!next || previous.t === next.t) return previous.cars
const span = next.t - previous.t
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
const cars: Record<string, ReplayCarPosition> = {}
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
for (const number of numbers) {
const before = previous.cars[number]
const after = next.cars[number]
if (before && after) {
cars[number] = {
x: before.x + (after.x - before.x) * fraction,
y: before.y + (after.y - before.y) * fraction,
}
} else if (before) {
cars[number] = before
} else if (after) {
cars[number] = after
}
}
return cars
}
export function replayCarToSvg(
car: ReplayCarPosition,
bounds: TrackBounds,
): ReplayCarPosition {
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, value))
}

View File

@@ -1,14 +0,0 @@
import type { ReplayFramesResponse, TrackOutline } from '../types'
/** Whether replay map can render (probe uses existing frames + outline queries). */
export function isReplayMapAvailable(
replay: ReplayFramesResponse | null | undefined,
outline: TrackOutline | null | undefined,
hasError: boolean,
): boolean {
if (hasError) return false
const frames = replay?.frames ?? []
if (frames.length < 2) return false
if (!outline?.points?.length) return false
return true
}

View File

@@ -1,61 +0,0 @@
// Pure helpers for the championship rivalry compare view.
/** One round that counted toward the head-to-head tally. */
export interface H2HRound {
round: number // 1-based season round
label: string
posA: number
posB: number
winner: 'a' | 'b'
}
export interface H2HTally {
a: number
b: number
/** Rounds counted in the tally, in season order. */
rounds: H2HRound[]
/** Rounds skipped because either driver had no finishing position. */
skipped: number
}
/**
* Per-round points gap (a b) across the rounds both cumulative series
* cover. Positive values mean driver A is ahead.
*/
export function gapSeries(a: number[], b: number[]): number[] {
const n = Math.min(a.length, b.length)
const out: number[] = []
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
return out
}
/**
* Race head-to-head: per round, the lower finishing position wins. Rounds
* where either driver has no position (0 / missing) are skipped from the
* tally, as are equal positions (defensive — races can't tie).
*/
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
const n = Math.max(posA.length, posB.length)
const rounds: H2HRound[] = []
let a = 0
let b = 0
let skipped = 0
for (let i = 0; i < n; i++) {
const pa = posA[i] ?? 0
const pb = posB[i] ?? 0
if (pa <= 0 || pb <= 0 || pa === pb) {
skipped++
continue
}
const winner = pa < pb ? 'a' : 'b'
if (winner === 'a') a++
else b++
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
}
return { a, b, rounds, skipped }
}
/** Last n counted rounds of a tally, for the mini strip. */
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
return tally.rounds.slice(-n)
}

View File

@@ -1,271 +0,0 @@
// Tyre degradation + pit-window estimation for the live timing page.
// Pure functions only — no React, no side effects — so everything is unit-testable.
//
// Lap-time samples are accumulated client-side from successive SSE snapshots
// (mirroring the gapHistory/battles precedent); nothing here talks to the server.
import type { LiveTimingRow } from './live'
import { driverCode } from './live'
import { parseIntervalSeconds } from './gapHistory'
/**
* Rough typical pit-lane time loss (entry + stop + exit vs a flying lap), in
* seconds. Per-track values are deliberately out of scope for v1.
*/
export const PIT_LOSS_SECONDS = 22
/** Minimum clean laps in the current stint before a slope is trustworthy. */
export const MIN_CLEAN_LAPS = 4
/**
* Laps more than this many seconds off the stint median are treated as
* outliers (traffic, spins, safety car) and excluded from the fit.
*/
export const OUTLIER_DELTA_SECONDS = 5
/**
* Slope classification thresholds (seconds per lap):
* slope <= -DEG_SLOPE_THRESHOLD -> 'improving' (track evolution, fuel burn dominating)
* slope >= +DEG_SLOPE_THRESHOLD -> 'degrading' (tyre wear dominating)
* otherwise -> 'stable'
*/
export const DEG_SLOPE_THRESHOLD = 0.05
/** Safety cap so a very long stint cannot grow the buffer unbounded. */
export const MAX_STINT_SAMPLES = 80
export type DegTrend = 'improving' | 'stable' | 'degrading'
export interface StintSample {
lap: number
seconds: number
}
export interface DriverStintHistory {
compound: string
tyreAge: number
lastLapNumber: number
lastLapTime: string
/** The next completed lap is an out-lap (fresh stint) and must be discarded. */
skipNextLap: boolean
samples: StintSample[]
}
/** Per-driver current-stint lap history, keyed by racing number. */
export type StintHistoryMap = Record<string, DriverStintHistory>
export interface StintLapInput {
racingNumber: string
lapNumber: number
lastLapTime: string
compound: string
tyreAge: number
inPit: boolean
pitOut: boolean
}
/**
* Parse an F1 live-timing lap time string ("1:23.456", "83.456") into seconds.
* Same conventions as parseIntervalSeconds, but a lap time is never signed and
* never a lapped/leader marker; zero or negative values are rejected.
*/
export function parseLapTimeSeconds(raw: string | null | undefined): number | null {
const value = parseIntervalSeconds(raw)
if (value === null || value <= 0) return null
return value
}
export function stintInputFromRow(row: LiveTimingRow): StintLapInput {
return {
racingNumber: row.RacingNumber,
lapNumber: row.Driver.NumberOfLaps || 0,
lastLapTime: row.Driver.LastLapTime || '',
compound: row.Tyre?.Compound || '',
tyreAge: row.Tyre?.Age ?? 0,
inPit: Boolean(row.Driver.InPit),
pitOut: Boolean(row.Driver.PitOut),
}
}
/**
* Record one snapshot's worth of lap samples for each driver's current stint.
* Returns a new map (input is not mutated); drivers missing from `inputs` are
* pruned.
*
* A lap counts as completed when LastLapTime changes to a new non-empty value
* (NumberOfLaps can tick before the lap time arrives, so the time string is
* the trigger; a sample for the same lap number is replaced, not duplicated).
* The stint buffer resets when the compound changes or the tyre age drops
* (new set fitted), and laps completed in the pit lane (in-lap), on pit exit
* (out-lap), or immediately after a reset are excluded.
*/
export function recordStintSamples(
history: StintHistoryMap,
inputs: ReadonlyArray<StintLapInput>,
): StintHistoryMap {
const next: StintHistoryMap = {}
for (const input of inputs) {
if (!input.racingNumber) continue
const prior = history[input.racingNumber]
if (!prior) {
next[input.racingNumber] = {
compound: input.compound,
tyreAge: input.tyreAge,
lastLapNumber: input.lapNumber,
lastLapTime: input.lastLapTime,
skipNextLap: false,
samples: [],
}
continue
}
let samples = prior.samples
let skipNextLap = prior.skipNextLap
const compoundChanged = Boolean(input.compound) && Boolean(prior.compound) && input.compound !== prior.compound
const freshSet = input.tyreAge < prior.tyreAge
if (compoundChanged || freshSet || input.inPit) {
// Stint over (or a new one starting): drop the old laps and flag the
// upcoming out-lap for exclusion.
samples = []
skipNextLap = true
}
const lapCompleted = Boolean(input.lastLapTime) && input.lastLapTime !== prior.lastLapTime
if (lapCompleted) {
const seconds = parseLapTimeSeconds(input.lastLapTime)
const dirty = input.inPit || input.pitOut || skipNextLap
if (seconds !== null && !dirty) {
const last = samples[samples.length - 1]
if (last && last.lap === input.lapNumber) {
samples = [...samples.slice(0, -1), { lap: input.lapNumber, seconds }]
} else {
samples = [...samples, { lap: input.lapNumber, seconds }]
if (samples.length > MAX_STINT_SAMPLES) {
samples = samples.slice(samples.length - MAX_STINT_SAMPLES)
}
}
}
if (!input.inPit) skipNextLap = false
}
next[input.racingNumber] = {
compound: input.compound || prior.compound,
tyreAge: input.tyreAge,
lastLapNumber: input.lapNumber,
lastLapTime: input.lastLapTime || prior.lastLapTime,
skipNextLap,
samples,
}
}
return next
}
export interface DegModel {
/** Least-squares slope of lap time vs lap number, in seconds per lap. */
slope: number
trend: DegTrend
/** Clean (outlier-filtered) samples the fit ran over, in lap order. */
samples: StintSample[]
}
/** Drop laps more than OUTLIER_DELTA_SECONDS off the stint median. */
export function cleanStintSamples(
samples: ReadonlyArray<StintSample>,
maxDelta = OUTLIER_DELTA_SECONDS,
): StintSample[] {
if (samples.length === 0) return []
const sorted = samples.map((sample) => sample.seconds).sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
return samples.filter((sample) => Math.abs(sample.seconds - median) <= maxDelta)
}
/**
* Fit a linear degradation model over the stint's clean laps.
* Returns null with fewer than MIN_CLEAN_LAPS clean samples — the caller
* should show a "warming up" placeholder rather than a junk slope.
*/
export function degradationModel(samples: ReadonlyArray<StintSample>): DegModel | null {
const clean = cleanStintSamples(samples)
if (clean.length < MIN_CLEAN_LAPS) return null
const n = clean.length
const meanLap = clean.reduce((sum, s) => sum + s.lap, 0) / n
const meanSec = clean.reduce((sum, s) => sum + s.seconds, 0) / n
let numerator = 0
let denominator = 0
for (const sample of clean) {
numerator += (sample.lap - meanLap) * (sample.seconds - meanSec)
denominator += (sample.lap - meanLap) ** 2
}
if (denominator === 0) return null
const slope = numerator / denominator
const trend: DegTrend =
slope <= -DEG_SLOPE_THRESHOLD ? 'improving' : slope >= DEG_SLOPE_THRESHOLD ? 'degrading' : 'stable'
return { slope, trend, samples: clean }
}
/** "+0.08s/lap" / "-0.12s/lap" display form. */
export function formatSlope(slope: number): string {
const sign = slope >= 0 ? '+' : ''
return `${sign}${Math.abs(slope).toFixed(2)}s/lap`
}
export interface PitRejoinEstimate {
rejoinPosition: number
/** Cars behind that would get past during the stop. */
positionsLost: number
/** Driver code directly ahead after rejoining, if any. */
aheadCode: string | null
/** Driver code directly behind after rejoining, if any. */
behindCode: string | null
}
/**
* Estimate where a driver rejoins after a pit stop costing `pitLoss` seconds.
*
* Walks the cars behind the driver, accumulating their Interval (gap to car
* ahead) values: every car whose cumulative gap to the driver is under the
* pit loss gets past. An unparsable interval (lapped marker like "1L") ends
* the walk — those cars are at least a lap down and stay behind. Cars in the
* pits or retired are excluded from the ladder.
*/
export function estimatePitRejoin(
rows: ReadonlyArray<LiveTimingRow>,
racingNumber: string,
pitLoss = PIT_LOSS_SECONDS,
): PitRejoinEstimate | null {
const ladder = rows
.filter((row) => row.Position > 0 && !row.Driver.Retired && !row.Driver.InPit)
.sort((a, b) => a.Position - b.Position)
const index = ladder.findIndex((row) => row.RacingNumber === racingNumber)
if (index === -1) return null
let cumulative = 0
let positionsLost = 0
for (let i = index + 1; i < ladder.length; i++) {
const gap = parseIntervalSeconds(ladder[i].Driver.Interval)
if (gap === null) break
cumulative += Math.max(0, gap)
if (cumulative >= pitLoss) break
positionsLost += 1
}
const ahead = positionsLost > 0 ? ladder[index + positionsLost] : ladder[index - 1]
const behind = ladder[index + positionsLost + 1]
return {
rejoinPosition: ladder[index].Position + positionsLost,
positionsLost,
aheadCode: ahead ? driverCode(ahead) : null,
behindCode: behind ? driverCode(behind) : null,
}
}

View File

@@ -1,33 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchChampionshipHub,
fetchNews,
fetchNewsArticle,
fetchSeasonMeetings,
fetchSeasons,
markNewsRead,
} from '../api'
import {
activeDigestWindow,
filterByTag,
groupByWindow,
gpWindows,
itemsForWindow,
sinceLastLabel,
sortWindowBucketsNewestFirst,
tagColour,
tagItems,
topTags,
type DigestTag,
type TaggedNewsItem,
} from '../lib/digest'
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
import { stripHtml, timeAgo } from '../utils'
import type { ArticleContent, NewsItem } from '../types'
import '../styles/digest.css'
type Category = 'all' | 'official' | 'news' | 'video'
const PAGE_SIZE = 16
const CATEGORY_LABELS: Record<Category, string> = {
all: 'All',
official: 'Official',
@@ -91,31 +71,6 @@ function CategoryTabs({
)
}
function TagChip({
tag,
active,
onClick,
className = 'digest-tag-chip',
}: {
tag: DigestTag
active?: boolean
onClick?: () => void
className?: string
}) {
return (
<button
type="button"
className={`${className}${active ? ' active' : ''}`}
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
onClick={onClick}
aria-pressed={active}
>
<span className="digest-tag-dot" aria-hidden="true" />
{tag.label}
</button>
)
}
function OGImage({ url, title }: { url?: string; title: string }) {
const [failed, setFailed] = useState(false)
const initial = (title[0] ?? '?').toUpperCase()
@@ -143,15 +98,11 @@ function OGImage({ url, title }: { url?: string; title: string }) {
function BriefingCard({
item,
isActive,
activeTag,
onSelect,
onTagClick,
}: {
item: TaggedNewsItem
item: NewsItem
isActive: boolean
activeTag: string | null
onSelect: (item: NewsItem) => void
onTagClick: (tag: DigestTag) => void
}) {
const isRead = !!item.read_at
const isVideo = categoryOf(item) === 'video'
@@ -178,25 +129,6 @@ function BriefingCard({
{stripHtml(item.og_description || item.summary || '')}
</p>
)}
{item.tags.length > 0 && (
<div className="bp-card-tags">
{item.tags.map((tag) => (
<button
key={tag.key}
type="button"
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
onClick={(e) => {
e.stopPropagation()
onTagClick(tag)
}}
>
<span className="digest-tag-dot" aria-hidden="true" />
{tag.label}
</button>
))}
</div>
)}
</div>
</article>
)
@@ -214,6 +146,7 @@ function ReaderPanel({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -222,6 +155,7 @@ function ReaderPanel({
return () => document.removeEventListener('keydown', handler)
}, [onClose])
// Fetch article when item changes
useEffect(() => {
if (!item) {
setArticle(null)
@@ -241,14 +175,9 @@ function ReaderPanel({
.catch((err) => { setError(String(err)); setLoading(false) })
}, [item?.url, item ? categoryOf(item) : ''])
// Scroll panel to top when item changes
useEffect(() => {
const panel = panelRef.current
if (!panel) return
if (typeof panel.scrollTo === 'function') {
panel.scrollTo({ top: 0 })
} else {
panel.scrollTop = 0
}
panelRef.current?.scrollTo({ top: 0 })
}, [item?.url])
const isOpen = item !== null
@@ -368,6 +297,7 @@ function ReaderPanel({
{article && !loading && article.content && (
<div
className="bp-reader-body"
// readability strips scripts/iframes; sources are all known news outlets
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: article.content }}
/>
@@ -402,9 +332,8 @@ function ReaderPanel({
export function BriefingPage() {
const [activeCategory, setActiveCategory] = useState<Category>('all')
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
const [activeTag, setActiveTag] = useState<string | null>(null)
const [page, setPage] = useState(1)
const queryClient = useQueryClient()
const now = useMemo(() => new Date(), [])
const { data: allNews = [], isLoading, isError } = useQuery({
queryKey: ['news'],
@@ -412,74 +341,13 @@ export function BriefingPage() {
staleTime: 60_000,
})
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['season-meetings', latestSeason],
queryFn: () => fetchSeasonMeetings(latestSeason!),
enabled: latestSeason != null,
})
const hubQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const meetings = meetingsQuery.data ?? []
const hub = hubQuery.data
const taggedNews = useMemo(
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
[allNews, hub],
const filtered = allNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
)
const categoryFiltered = useMemo(
() => taggedNews.filter(
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
),
[taggedNews, activeCategory],
)
const tagFiltered = useMemo(
() => filterByTag(categoryFiltered, activeTag),
[categoryFiltered, activeTag],
)
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
const windowSections = useMemo(
() => sortWindowBucketsNewestFirst(grouped.windows),
[grouped.windows],
)
const activeWindow = useMemo(
() => activeDigestWindow(windows, meetings, now),
[windows, meetings, now],
)
const sinceLastItems = useMemo(
() => itemsForWindow(tagFiltered, activeWindow),
[tagFiltered, activeWindow],
)
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
const sinceLabel = sinceLastLabel(meetings, now)
const taggedByUrl = useMemo(() => {
const map = new Map<string, TaggedNewsItem>()
for (const item of tagFiltered) map.set(item.url, item)
return map
}, [tagFiltered])
const recentTagged = useMemo(() => {
const urls = new Set(grouped.recent.map((item) => item.url))
return tagFiltered.filter((item) => urls.has(item.url))
}, [grouped.recent, tagFiltered])
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount)
const pageStart = (safePage - 1) * PAGE_SIZE
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
const counts: Record<Category, number> = {
all: allNews.length,
@@ -506,14 +374,22 @@ export function BriefingPage() {
const handleClose = useCallback(() => setSelectedItem(null), [])
const handleTagClick = useCallback((tag: DigestTag) => {
setActiveTag((current) => (current === tag.key ? null : tag.key))
setSelectedItem(null)
}, [])
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
const handlePageChange = useCallback(
(nextPage: number) => {
setPage(Math.min(Math.max(nextPage, 1), pageCount))
setSelectedItem(null)
window.scrollTo({ top: 0, behavior: 'smooth' })
},
[pageCount],
)
const unreadCount = allNews.filter((i) => !i.read_at).length
const hasDigest = meetings.length > 0
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
const showPagination = filtered.length > PAGE_SIZE
return (
<div className="bp-page" data-testid="briefing-page">
@@ -532,106 +408,74 @@ export function BriefingPage() {
<CategoryTabs
active={activeCategory}
counts={counts}
onChange={(c) => {
setActiveCategory(c)
setSelectedItem(null)
setActiveTag(null)
}}
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
/>
{hasDigest && (
<div className="digest-sticky" data-testid="digest-sticky-header">
<div className="digest-sticky-head">
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
<span className="digest-sticky-count">
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
</span>
</div>
{(sinceLastTags.length > 0 || activeTag) && (
<div className="digest-sticky-tags">
{sinceLastTags.map((tag) => (
<TagChip
key={tag.key}
tag={tag}
active={activeTag === tag.key}
onClick={() => handleTagClick(tag)}
/>
))}
{activeTag && (
<button
type="button"
className="digest-filter-clear"
onClick={() => setActiveTag(null)}
data-testid="digest-filter-clear"
>
Clear filter
</button>
)}
</div>
)}
</div>
)}
{showEmpty ? (
{filtered.length === 0 ? (
<div className="bp-empty">
No {activeCategory !== 'all' ? activeCategory : ''} items available.
{activeTag ? ' Try clearing the tag filter.' : ''}
{!activeTag && (
<>
{' '}
Run <code>box-box --ingest-news</code> to refresh feeds.
</>
)}
Run <code>box-box --ingest-news</code> to refresh feeds.
</div>
) : (
<>
{windowSections.map(({ window, items }) => (
<section
key={window.meeting_key}
className="digest-section"
data-testid={`digest-window-${window.meeting_key}`}
>
<div className="digest-section-head">
<h3 className="digest-section-title">{window.meeting_name}</h3>
<span className="digest-section-count">{items.length}</span>
{showPagination && (
<div className="bp-pagination bp-pagination-top">
<span className="bp-pagination-meta mono">
{pageStart + 1}-{pageEnd} of {filtered.length}
</span>
<div className="bp-pagination-actions">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{items.map((item) => {
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
return (
<BriefingCard
key={item.url}
item={tagged}
isActive={selectedItem?.url === item.url}
activeTag={activeTag}
onSelect={handleSelect}
onTagClick={handleTagClick}
/>
)
})}
</div>
</section>
))}
</div>
)}
{recentTagged.length > 0 && (
<section className="digest-section digest-recent-section" data-testid="digest-recent">
<div className="digest-section-head">
<h3 className="digest-section-title">Recent</h3>
<span className="digest-section-count">{recentTagged.length}</span>
</div>
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{recentTagged.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
activeTag={activeTag}
onSelect={handleSelect}
onTagClick={handleTagClick}
/>
))}
</div>
</section>
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
{pagedItems.map((item) => (
<BriefingCard
key={item.url}
item={item}
isActive={selectedItem?.url === item.url}
onSelect={handleSelect}
/>
))}
</div>
{showPagination && (
<div className="bp-pagination bp-pagination-bottom">
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage - 1)}
disabled={safePage === 1}
>
Previous
</button>
<span className="bp-page-current mono">
Page {safePage} / {pageCount}
</span>
<button
className="bp-page-btn"
onClick={() => handlePageChange(safePage + 1)}
disabled={safePage === pageCount}
>
Next
</button>
</div>
)}
</>
)}

View File

@@ -1,17 +1,11 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
const GOLD = '#ffd700'
const SILVER = '#c0c0c0'
@@ -144,8 +138,6 @@ 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,
@@ -217,14 +209,6 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
>
Progression
</button>
<button
type="button"
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
onClick={() => setView('rivalry')}
data-testid="champ-tab-rivalry"
>
Rivalry
</button>
<button
type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
@@ -249,18 +233,10 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
</div>
{view === 'drivers' && (
<DriversView
enriched={enriched}
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
drivers={drivers}
season={hub.season}
/>
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div>
)
@@ -272,8 +248,6 @@ interface EnrichedDriver {
color: string
gapLeader: string
gapAhead: string
gapAheadNum: number | null
driverAhead: string | null
spark: string
h2h: string
h2hWin: boolean
@@ -285,20 +259,12 @@ function DriversView({
enriched,
leaderPoints,
titleMath,
roundsLeft,
drivers,
season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
drivers: ChampHubDriver[]
season: number
}) {
const podium = enriched.slice(0, 3)
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
return (
<div data-testid="champ-view-drivers">
<div className="champ-podium">
@@ -357,29 +323,6 @@ function DriversView({
<span className="champ-titlemath-text">{titleMath}</span>
</div>
{h2hPairs.length > 0 && (
<section className="h2h-section" data-testid="champ-teammate-battles">
<div className="h2h-section-head">
<h2 className="h2h-section-title">Teammate battles</h2>
<span className="h2h-section-sub">race finishes · closest first</span>
</div>
<div className="h2h-list">
{h2hPairs.map((pair) => (
<TeammateH2H
key={pair.teamName}
teamName={pair.teamName}
teamColour={pair.teamColour}
driverATla={pair.driverA.name_acronym}
driverBTla={pair.driverB.name_acronym}
winsA={pair.driverA.teammate_wins}
winsB={pair.driverB.teammate_wins}
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
/>
))}
</div>
</section>
)}
<div className="champ-scroll">
<table className="champ-table champ-table-drivers">
<thead>
@@ -404,36 +347,17 @@ function DriversView({
P{e.pos}
</td>
<td>
<Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(e.d.driver_number) }}
search={{ year: season }}
className="champ-drv"
style={{ color: 'inherit', textDecoration: 'none' }}
data-testid={`champ-driver-link-${e.d.driver_number}`}
>
<div className="champ-drv">
<span className="champ-drv-bar" style={{ background: e.color }} />
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
</Link>
</div>
</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-muted">{e.gapLeader}</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="r mono champ-td-dim">{e.gapAhead}</td>
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins}
</td>

View File

@@ -1,38 +1,38 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
fetchChampionshipHub,
fetchLiveState,
fetchLocalMeetings,
fetchRaceHub,
fetchSeasonMeetings,
fetchSeasons,
fetchSessions,
fetchWeekend,
} from '../api'
import { CommandCenterHero } from '../components/CommandCenterHero'
import { PaddockBriefing } from '../components/PaddockBriefing'
import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchSessions, fetchWeekend, fetchChampionshipHub } from '../api'
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import { classifySessionStatus, heroState } from '../lib/hero'
import {
currentAndNextSession,
focusMeetingKind,
formatCountdown,
formatSessionScheduleTime,
meetingHasStarted,
mostRecentPastMeeting,
nextUpcomingMeeting,
pickFocusMeeting,
sessionEndTime,
sessionStartTime,
sortSessionsByStart,
} from '../lib/schedule'
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { Trophy } from 'lucide-react'
import { PaddockBriefing } from '../components/PaddockBriefing'
import { Play, Activity, Calendar, Trophy } from 'lucide-react'
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
const missingDatasets = Object.fromEntries(
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
) as WeekendSession['datasets']
function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
const start = sessionStartTime(session)
const end = sessionEndTime(session)
if (start && end && now >= start && now < end) return 'live'
if (start && now >= start) return 'done'
return 'upcoming'
}
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past'
@@ -148,36 +148,7 @@ export function CommandCenterPage() {
focusWeekendSessions[0]?.session
const analysisSessionKey = actionSession?.session_key
const liveActive = liveQuery.data?.is_live === true
const heroStateKind = heroState({
now: nowDate,
liveActive,
currentSession,
focusKind,
})
const lastPastMeeting = useMemo(
() => mostRecentPastMeeting(focusMeetings, nowDate),
[focusMeetings, nowDate],
)
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
staleTime: 60_000,
})
const nextMeetingForHero = useMemo(() => {
if (heroStateKind !== 'between') return null
return nextUpcomingMeeting(focusMeetings, nowDate)
}, [heroStateKind, focusMeetings, nowDate])
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div>
@@ -218,7 +189,19 @@ export function CommandCenterPage() {
)
}
const liveActive = liveQuery.data?.is_live === true
const statusKind: WeekendStatusKind = liveActive
? 'live'
: focusKind === 'current'
? 'current'
: focusKind === 'next'
? 'next'
: focusKind === 'recent'
? 'recent'
: 'fallback'
const accent = countryAccent(focusMeeting ?? null)
const decal = countryDecal(focusMeeting ?? null)
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
return (
@@ -241,25 +224,76 @@ export function CommandCenterPage() {
</div>
)}
{focusMeeting && focusKind && (
<CommandCenterHero
state={heroStateKind}
now={nowDate}
accent={accent}
liveActive={liveActive}
liveData={liveQuery.data?.data}
focusMeeting={focusMeeting}
focusKind={focusKind}
sessions={focusWeekendSessions}
currentSession={currentSession}
nextSession={nextSession}
analysisSessionKey={analysisSessionKey}
analysisSessionName={actionSession?.session_name}
lastRaceName={lastRaceName}
lastRacePodium={lastRacePodium}
lastRaceSessionKey={lastRaceSessionKey}
nextMeeting={nextMeetingForHero}
/>
{focusMeeting && (
<div className="cc-hero">
<section className="cc-weekend-band ui-card glass-panel" data-testid="cc-focus">
<div className="cc-band-accent" aria-hidden="true" />
<div className="cc-band-body">
<div className="cc-band-row">
<span className="cc-band-decal mono">{decal}</span>
<div className="cc-band-titles">
<div className="cc-band-eyebrow mono">
<WeekendKindLabel kind={statusKind} />
</div>
<h1 className="cc-band-name">{focusMeeting.meeting_name}</h1>
<div className="cc-band-sub mono">
{[focusMeeting.location, focusMeeting.circuit_short_name]
.filter(Boolean)
.join(' · ')}
</div>
<div className="cc-band-sub mono cc-band-dates">{formatGpDateRange(focusMeeting)}</div>
</div>
<CountdownBlock
liveActive={liveActive}
currentSession={currentSession}
nextSession={nextSession}
meeting={focusMeeting}
now={nowDate}
/>
</div>
</div>
</section>
<div className="cc-actions-row" data-testid="cc-actions">
<Link
to="/live"
className={`cc-pri-action ui-card interactive ${liveActive ? 'is-live ui-cta-primary' : ''}`}
data-testid="cc-action-live"
>
<span className="cc-pri-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Play size={16} /> Watch Live</span>
<span className="cc-pri-meta mono">{liveActive ? 'Feed active' : 'Standby'}</span>
</Link>
<Link
to="/race-hub"
search={analysisSessionKey ? { session_key: analysisSessionKey } : {}}
className="cc-pri-action ui-card interactive"
data-testid="cc-action-race-hub"
>
<span className="cc-pri-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Activity size={16} /> Open Analysis</span>
<span className="cc-pri-meta mono">
{actionSession
? `${actionSession.session_name} · session ${actionSession.session_key}`
: 'Pick a session'}
</span>
</Link>
{nextSession && sessionStartTime(nextSession) && (
<a
href="#cc-schedule"
className="cc-pri-action ui-card interactive"
data-testid="cc-action-schedule"
onClick={(e) => {
e.preventDefault()
document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' })
}}
>
<span className="cc-pri-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Calendar size={16} /> Schedule</span>
<span className="cc-pri-meta mono">
next: {nextSession.session_name}
</span>
</a>
)}
</div>
</div>
)}
<div className="cc-dashboard-grid">
@@ -449,3 +483,65 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
)
}
function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
switch (kind) {
case 'live':
return <span className="cc-kind cc-kind-live"> Live now</span>
case 'current':
return <span className="cc-kind cc-kind-current">Current weekend</span>
case 'next':
return <span className="cc-kind cc-kind-next">Next weekend</span>
case 'recent':
return <span className="cc-kind cc-kind-recent">Recent weekend</span>
default:
return <span className="cc-kind">Weekend</span>
}
}
interface CountdownBlockProps {
liveActive: boolean
currentSession: Session | null
nextSession: Session | null
meeting: Meeting
now: Date
}
function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) {
if (liveActive) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">SignalR</div>
<div className="cc-cd-value cc-cd-live">LIVE</div>
<div className="cc-cd-sub mono">{currentSession?.session_name ?? 'Feed connected'}</div>
</div>
)
}
if (currentSession) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">On Track</div>
<div className="cc-cd-value cc-cd-current">{currentSession.session_name}</div>
<div className="cc-cd-sub mono">In session</div>
</div>
)
}
if (nextSession && sessionStartTime(nextSession)) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">Next · {nextSession.session_name}</div>
<div className="cc-cd-value mono">{formatCountdown(sessionStartTime(nextSession)!, now)}</div>
<div className="cc-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
</div>
)
}
if (meetingHasStarted(meeting, now)) {
return (
<div className="cc-countdown-block">
<div className="cc-cd-label mono">Status</div>
<div className="cc-cd-value cc-cd-done">Complete</div>
<div className="cc-cd-sub mono">Weekend finished</div>
</div>
)
}
return null
}

View File

@@ -1,369 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchDriverSummary, fetchSeasons } from '../api'
import { teamColor } from '../utils'
import { countryFlag } from '../lib/gpIdentity'
import {
formatDelta,
formatPosition,
gridFinishDeltas,
type RoundDelta,
} from '../lib/driverProfile'
import type { DriverSummary } from '../types'
import '../styles/driver-profile.css'
interface Props {
driverNumber: number
year?: number
}
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
export function DriverProfilePage({ driverNumber, year }: Props) {
// Without an explicit ?year=, default to the latest ingested season; if the
// seasons list is empty/unavailable the backend falls back to the current year.
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
enabled: year == null,
})
const resolvedYear = year ?? seasonsQuery.data?.[0]
const seasonsSettled = year != null || !seasonsQuery.isLoading
const summaryQuery = useQuery({
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
enabled: seasonsSettled && driverNumber > 0,
staleTime: 5 * 60_000,
})
if (driverNumber <= 0) {
return <div className="page error-box">Invalid driver number</div>
}
if (!seasonsSettled || summaryQuery.isLoading) {
return <div className="page loading-state">loading driver profile</div>
}
if (summaryQuery.isError) {
return (
<div className="page error-box">
{summaryQuery.error instanceof Error
? summaryQuery.error.message
: 'Failed to load driver profile'}
</div>
)
}
const summary = summaryQuery.data
if (!summary) {
return <div className="page error-box">No driver data</div>
}
return <DriverProfileBody summary={summary} />
}
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
const color = teamColor(summary.team_colour)
const deltas = gridFinishDeltas(summary.rounds)
return (
<div className="dp-page" data-testid="driver-profile">
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity">
<div className="dp-title-row">
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
<span className="dp-num mono" style={{ color }}>
#{summary.driver_number}
</span>
</div>
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
<div className="dp-team">
<span className="dp-team-swatch" style={{ background: color }} />
{summary.team_name || 'Unknown team'}
<span className="dp-season mono">{summary.season}</span>
</div>
</div>
<div className="dp-stat-rail">
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
<div className="dp-stat-label">Championship</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
<div className="dp-stat-label">Points</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.wins}</div>
<div className="dp-stat-label">Wins</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.podiums}</div>
<div className="dp-stat-label">Podiums</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.poles}</div>
<div className="dp-stat-label">Poles</div>
</div>
</div>
<Link to="/championship" className="dp-back mono">
Championship
</Link>
</header>
<section className="dp-section" data-testid="dp-form">
<h2 className="dp-section-title mono">Season form</h2>
{summary.cumulative.length > 0 ? (
<div className="dp-form-grid">
<CumulativeLine
cumulative={summary.cumulative}
labels={summary.round_labels}
color={color}
/>
<FormStrip form={summary.form} color={color} />
</div>
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-quali-race">
<h2 className="dp-section-title mono">Quali vs race</h2>
{deltas.some((d) => d.delta != null) ? (
<GridVsRaceChart deltas={deltas} color={color} />
) : (
<div className="dp-empty">No grid-vs-finish data yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-rounds">
<h2 className="dp-section-title mono">Track by track</h2>
{summary.rounds.length > 0 ? (
<RoundsTable summary={summary} deltas={deltas} />
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
</div>
)
}
const LINE_W = 640
const LINE_H = 180
const LINE_PL = 44
const LINE_PR = 16
const LINE_PT = 12
const LINE_PB = 24
function CumulativeLine({
cumulative,
labels,
color,
}: {
cumulative: number[]
labels: string[]
color: string
}) {
const n = cumulative.length
const maxY = Math.max(25, ...cumulative)
const plotW = LINE_W - LINE_PL - LINE_PR
const plotH = LINE_H - LINE_PT - LINE_PB
const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
const tickStep = Math.max(1, Math.ceil(n / 8))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += tickStep) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
return (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
{[0, 0.5, 1].map((f) => {
const v = Math.round(maxY * f)
return (
<g key={f}>
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
{v}
</text>
</g>
)
})}
{ticks.map((t) => (
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
{t.label}
</text>
))}
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
</svg>
</figure>
)
}
function FormStrip({ form, color }: { form: number[]; color: string }) {
const max = Math.max(25, ...form)
return (
<div className="dp-form-strip">
<div className="dp-chart-caption mono">Last {form.length} races</div>
<div className="dp-form-bars">
{form.map((pts, i) => (
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
<div
className="dp-form-bar-fill"
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
/>
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
</div>
))}
</div>
</div>
)
}
const DELTA_H = 190
const DELTA_PT = 14
const DELTA_PB = 30
function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
const colW = 34
const w = Math.max(240, deltas.length * colW)
const plotH = DELTA_H - DELTA_PT - DELTA_PB
const zeroY = DELTA_PT + plotH / 2
const scale = plotH / 2 / maxAbs
return (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">
Positions gained () / lost () from grid to flag, per round
</figcaption>
<div className="dp-chart-scroll">
<svg
viewBox={`0 0 ${w} ${DELTA_H}`}
width={w}
height={DELTA_H}
className="dp-delta-svg"
role="img"
aria-label="Positions gained or lost per round"
>
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
{deltas.map((d, i) => {
const cx = i * colW + colW / 2
if (d.delta == null) {
return (
<g key={d.round}>
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
·
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
}
const h = Math.abs(d.delta) * scale
const yTop = d.delta >= 0 ? zeroY - h : zeroY
return (
<g key={d.round}>
<title>{`${d.label}: ${formatPosition(d.grid)}${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
<rect
x={cx - 8}
y={yTop}
width={16}
height={Math.max(1.5, h)}
rx={2}
fill={color}
opacity={d.delta >= 0 ? 0.95 : 0.45}
/>
<text
x={cx}
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
textAnchor="middle"
className="dp-delta-val mono"
>
{formatDelta(d.delta)}
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
})}
</svg>
</div>
</figure>
)
}
function statusNote(d: RoundDelta): string {
if (d.status === 'dnf') return 'DNF'
if (d.status === 'dns') return 'DNS'
if (d.status === 'dsq') return 'DSQ'
if (d.status === 'absent') return '—'
return ''
}
function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
return (
<div className="dp-scroll">
<table className="dp-table">
<thead>
<tr>
<th className="l">Rnd</th>
<th className="l">Grand Prix</th>
<th className="r">Grid</th>
<th className="r">Race</th>
<th className="r">Δ</th>
<th className="r">Pts</th>
<th className="l"></th>
</tr>
</thead>
<tbody>
{summary.rounds.map((r, i) => {
const d = deltas[i]
return (
<tr key={r.meeting_key}>
<td className="mono dp-td-dim">R{i + 1}</td>
<td>
<span className="dp-flag" aria-hidden="true">
{countryFlag({ country_code: r.country_code, country_flag: '' })}
</span>
{r.meeting_name}
</td>
<td className="r mono">{formatPosition(d.grid)}</td>
<td className="r mono">{formatPosition(d.finish)}</td>
<td
className="r mono"
style={{
color:
d.delta == null
? 'var(--text-3)'
: d.delta > 0
? 'var(--green)'
: d.delta < 0
? 'var(--red)'
: 'var(--text-2)',
}}
>
{formatDelta(d.delta)}
</td>
<td className="r mono">{fmtPts(r.points)}</td>
<td className="dp-td-dim mono">{statusNote(d)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import type { LivePosition, LiveStreamData } from '../types'
import {
loadPinnedDrivers,
@@ -15,27 +15,19 @@ import type { VisibleSectorState } from '../lib/live'
import type { GapHistoryMap } from '../lib/gapHistory'
import { recordGapSamples } from '../lib/gapHistory'
import { battleNumbers, detectBattles } from '../lib/battles'
import type { LiveEvent } from '../lib/events'
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
import { SessionBanner } from '../components/live/SessionBanner'
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { EventRail } from '../components/live/EventRail'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import { Archive, Radio } from 'lucide-react'
import { TrackMap } from '../components/live/TrackMap'
import { Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export function LiveTimingPage() {
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
const [archiveMode, setArchiveMode] = useState(false)
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
const [isLive, setIsLive] = useState(false)
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
const [now, setNow] = useState(Date.now())
@@ -43,13 +35,6 @@ export function LiveTimingPage() {
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const [events, setEvents] = useState<LiveEvent[]>([])
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
const sessionSigRef = useRef('')
const isLiveRef = useRef(false)
const archiveModeRef = useRef(false)
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
const hasArchive = Boolean(archiveSnapshot)
const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'],
@@ -57,31 +42,24 @@ export function LiveTimingPage() {
staleTime: 5_000,
})
const trackOutlineQuery = useQuery({
queryKey: [
'live-track-outline',
snapshot?.Session?.MeetingName ?? '',
snapshot?.Session?.CircuitName ?? '',
],
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
staleTime: Infinity,
retry: false,
})
useEffect(() => {
if (!data) return
const nextLive = data.is_live && Boolean(data.data)
setIsLive(nextLive)
isLiveRef.current = nextLive
if (nextLive && data.data) {
setActiveSnapshot(data.data)
setArchiveMode(false)
return
}
setActiveSnapshot(null)
setArchiveSnapshot(data.last_snapshot ?? null)
setArchivePositions(data.last_positions ?? {})
setArchiveSnapshotAt(data.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(data.last_snapshot))
setIsLive(data.is_live)
setSnapshot(data.data)
}, [data])
useEffect(() => {
archiveModeRef.current = archiveMode
if (archiveMode) {
setPositions(archivePositions)
}
}, [archiveMode, archivePositions])
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
@@ -104,23 +82,8 @@ export function LiveTimingPage() {
events.addEventListener('snapshot', (event) => {
const state = parseLiveStateEvent(event.data)
if (!state || cancelled) return
const nextLive = state.is_live && Boolean(state.data)
setIsLive(nextLive)
if (nextLive && state.data) {
if (!isLiveRef.current || archiveModeRef.current) {
setPositions({})
}
isLiveRef.current = true
setActiveSnapshot(state.data)
setArchiveMode(false)
} else {
isLiveRef.current = false
setActiveSnapshot(null)
setArchiveSnapshot(state.last_snapshot ?? null)
setArchivePositions(state.last_positions ?? {})
setArchiveSnapshotAt(state.last_snapshot_at ?? null)
setArchiveMode((current) => current && Boolean(state.last_snapshot))
}
setIsLive(state.is_live)
setSnapshot(state.data)
setStreamStatus('connected')
})
@@ -149,23 +112,6 @@ export function LiveTimingPage() {
}
}, [])
// Synthesize "what just happened" events from successive snapshots; the
// buffer resets whenever a different session starts streaming.
useEffect(() => {
if (!snapshot) return
const signature = sessionSignature(snapshot.Session)
const prev = prevSnapshotRef.current
prevSnapshotRef.current = snapshot
if (signature !== sessionSigRef.current) {
sessionSigRef.current = signature
setEvents([])
return
}
if (!prev || prev === snapshot) return
const fresh = diffSnapshots(prev, snapshot)
if (fresh.length > 0) setEvents((current) => appendEvents(current, fresh))
}, [snapshot])
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
useEffect(() => {
@@ -203,19 +149,6 @@ export function LiveTimingPage() {
setPinned((prev) => togglePin(prev, racingNumber))
}
const handleViewArchive = () => {
if (!archiveSnapshot) return
setIsLive(false)
setArchiveMode(true)
setPositions(archivePositions)
}
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
const archiveLabel =
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
: 'Archived live timing snapshot'
return (
<div className="page live-page" data-testid="live-page">
{isError && (
@@ -224,18 +157,12 @@ export function LiveTimingPage() {
</div>
)}
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
{streamStatus === 'disconnected' && snapshot && (
<div className="live-status-strip live-status-warn">
Stream disconnected showing last received snapshot
</div>
)}
{archiveMode && snapshot && (
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
{archiveLabel} live updates are paused for this archive view
</div>
)}
{isLoading && !snapshot && (
<div className="loading-state">connecting to live timing</div>
)}
@@ -250,28 +177,22 @@ export function LiveTimingPage() {
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
</p>
{hasArchive && (
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
<Archive size={15} />
View Last Session
</button>
)}
</div>
)}
{snapshot && (
<>
<SessionBanner
isLive={isLive}
isArchive={archiveMode}
snapshot={snapshot}
rows={rows}
connection={streamStatus}
now={now}
/>
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
<TrackStatusBanner status={snapshot.TrackStatus} />
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
<TrackMap
outline={trackOutlineQuery.data}
positions={positions}
telemetry={snapshot.Telemetry}
drivers={snapshot.Drivers}
driverInfo={snapshot.DriverInfo}
loading={trackOutlineQuery.isLoading}
/>
<div className="live-columns">
<div className="live-tower-col">
<div className="sec-header">
@@ -290,13 +211,7 @@ export function LiveTimingPage() {
/>
</div>
<div className="live-rc-col">
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
</div>
</div>
</>

View File

@@ -13,7 +13,6 @@ import { TabBar, type Tab } from '../components/TabBar'
import { DatasetStatusView } from '../components/DatasetStatusView'
import { StrategyView } from '../components/StrategyView'
import { LapsView } from '../components/LapsView'
import { CompareView } from '../components/CompareView'
import { RaceControlView } from '../components/RaceControlView'
import { WeatherView } from '../components/WeatherView'
import { OverviewView } from '../components/OverviewView'
@@ -308,19 +307,6 @@ export function RaceHubPage({ sessionKey }: Props) {
</div>
)}
{activeTab === 'compare' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Driver Compare</span>
</div>
<CompareView
sessionKey={sessionKey}
results={data.results}
drivers={data.drivers}
/>
</div>
)}
{activeTab === 'lap_data' && (
<div className="data-section">
<div className="sec-header">

View File

@@ -1,442 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
fetchChampionshipHub,
fetchMeetings,
fetchResults,
fetchSeasons,
fetchSessions,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
import {
buildTitleFightContext,
countdownTargetSession,
extractPodium,
extractPole,
findPriorYearMeetingByCircuit,
findRaceSession,
formatPointsGap,
isSprintWeekend,
pickPreviewMeeting,
RACE_WIN_POINTS,
SPRINT_WEEKEND_MAX_POINTS,
} from '../lib/preview'
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
import { buildOutlinePath } from '../lib/trackmap'
import { teamColor } from '../utils'
import type { Meeting, Session, TrackOutline } from '../types'
import '../styles/preview.css'
function SectionState({
loading,
error,
empty,
emptyMessage,
children,
}: {
loading?: boolean
error?: Error | null
empty?: boolean
emptyMessage?: string
children: ReactNode
}) {
if (loading) {
return <div className="preview-section-state">Loading</div>
}
if (error) {
return (
<div className="preview-section-state error">
{error instanceof Error ? error.message : 'Failed to load'}
</div>
)
}
if (empty) {
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
}
return <>{children}</>
}
function TrackOutlineCard({
outline,
loading,
error,
accent,
}: {
outline: TrackOutline | null | undefined
loading: boolean
error: Error | null
accent: string
}) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
return (
<section className="preview-card" data-testid="preview-track-card">
<h2 className="preview-card-title">Circuit</h2>
<SectionState
loading={loading}
error={error}
empty={!outline || !outlinePath}
emptyMessage="Track outline unavailable for this circuit"
>
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
</SectionState>
</section>
)
}
function LastYearCard({
year,
loading,
error,
podium,
pole,
isFirstTime,
}: {
year: number | null
loading: boolean
error: Error | null
podium: ReturnType<typeof extractPodium>
pole: ReturnType<typeof extractPole>
isFirstTime: boolean
}) {
return (
<section className="preview-card" data-testid="preview-last-year-card">
<h2 className="preview-card-title">Last year here</h2>
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
<SectionState
loading={loading}
error={error}
empty={isFirstTime || podium.length === 0}
emptyMessage={
isFirstTime
? 'First time on the calendar — no prior race at this circuit'
: 'No race results available for the previous visit'
}
>
<div className="preview-podium">
{podium.map((place) => (
<div key={place.position} className="preview-podium-row">
<span className="preview-podium-pos">P{place.position}</span>
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
{place.name_acronym}
</span>
<span className="preview-podium-team">{place.team_name}</span>
</div>
))}
</div>
{pole && (
<p className="preview-pole">
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
({pole.team_name})
</p>
)}
</SectionState>
</section>
)
}
function TitleFightCard({
loading,
error,
drivers,
sprintWeekend,
season,
}: {
loading: boolean
error: Error | null
drivers: ReturnType<typeof buildTitleFightContext>
sprintWeekend: boolean
season: number | null
}) {
return (
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
<h2 className="preview-card-title">Title fight context</h2>
{season != null && <p className="preview-card-sub">{season} drivers&apos; championship · top 3</p>}
<SectionState
loading={loading}
error={error}
empty={drivers.length === 0}
emptyMessage="Championship standings not available"
>
<table className="preview-title-table">
<thead>
<tr>
<th>Driver</th>
<th>Pts</th>
<th>Gap</th>
<th>If win (+{RACE_WIN_POINTS})</th>
</tr>
</thead>
<tbody>
{drivers.map((d) => (
<tr key={d.name_acronym}>
<td>
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
</td>
<td className="mono">{d.points}</td>
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
<td className="mono">
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
</td>
</tr>
))}
</tbody>
</table>
{sprintWeekend && (
<p className="preview-sprint-note" data-testid="preview-sprint-note">
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
</p>
)}
</SectionState>
</section>
)
}
function PreviewHeader({
meeting,
sessions,
countdownSession,
now,
accent,
}: {
meeting: Meeting
sessions: Session[]
countdownSession: Session | null
now: Date
accent: string
}) {
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
return (
<header
className="preview-header"
data-testid="preview-header"
style={{ ['--preview-accent' as string]: accent }}
>
<div className="preview-header-accent" />
<div className="preview-header-body">
<div className="preview-header-top">
<div>
<h1 className="preview-gp-name">
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
{meeting.meeting_name}
</h1>
<p className="preview-circuit">
{meeting.circuit_short_name}
{meeting.location ? ` · ${meeting.location}` : ''}
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
</p>
</div>
{countdownTarget && (
<div className="preview-countdown" data-testid="preview-countdown">
<span className="preview-countdown-label">
{countdownSession?.session_name ?? 'Next session'}
</span>
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
</div>
)}
</div>
{sessions.length > 0 && (
<div className="preview-schedule" data-testid="preview-schedule">
{sessions.map((session) => (
<div key={session.session_key} className="preview-schedule-item">
<span className="preview-schedule-name">{session.session_name}</span>
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
</div>
))}
</div>
)}
</div>
</header>
)
}
export function RacePreviewPage() {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const nowDate = useMemo(() => new Date(now), [now])
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const latestSeason = seasonsQuery.data?.[0] ?? null
const meetingsQuery = useQuery({
queryKey: ['meetings', latestSeason, 'auto'],
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
enabled: latestSeason != null,
})
const previewMeeting = useMemo(
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
[meetingsQuery.data, nowDate],
)
const sessionsQuery = useQuery({
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
enabled: previewMeeting != null,
})
const sessions = useMemo(
() => sortSessionsByStart(sessionsQuery.data ?? []),
[sessionsQuery.data],
)
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
const priorMeetingsQuery = useQuery({
queryKey: ['meetings', priorYear, 'auto'],
queryFn: () => fetchMeetings(priorYear!, 'auto'),
enabled: priorYear != null && previewMeeting != null,
})
const priorMeeting = useMemo(
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
)
const priorSessionsQuery = useQuery({
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
enabled: priorMeeting != null,
})
const priorRaceSession = useMemo(
() => findRaceSession(priorSessionsQuery.data ?? []),
[priorSessionsQuery.data],
)
const priorResultsQuery = useQuery({
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
enabled: priorRaceSession != null,
})
const priorGridQuery = useQuery({
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
enabled: priorRaceSession != null,
})
const trackOutlineQuery = useQuery({
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
})
const championshipQuery = useQuery({
queryKey: ['championship-hub', latestSeason],
queryFn: () => fetchChampionshipHub(latestSeason!),
enabled: latestSeason != null,
})
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
const titleFight = useMemo(
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
[championshipQuery.data],
)
const accent = countryAccent(previewMeeting)
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
return <div className="page loading-state" data-testid="preview-loading">loading preview</div>
}
if (seasonsQuery.isError || meetingsQuery.isError) {
const err = seasonsQuery.error ?? meetingsQuery.error
return (
<div className="page error-box" data-testid="preview-error">
{err instanceof Error ? err.message : 'Failed to load preview'}
</div>
)
}
if (!previewMeeting) {
return (
<div className="preview-page" data-testid="preview-season-over">
<div className="preview-empty">
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
<h1 className="preview-empty-title">Season complete</h1>
<p className="preview-empty-sub">
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
published.
</p>
</div>
{titleFight.length > 0 && (
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight}
sprintWeekend={false}
season={latestSeason}
/>
)}
</div>
)
}
return (
<div className="preview-page" data-testid="preview-page">
<PreviewHeader
meeting={previewMeeting}
sessions={sessions}
countdownSession={countdownSession}
now={nowDate}
accent={accent}
/>
<div className="preview-grid">
<TrackOutlineCard
outline={trackOutlineQuery.data}
loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
accent={accent}
/>
<LastYearCard
year={priorMeeting?.year ?? priorYear}
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
error={
priorMeetingsQuery.isError
? (priorMeetingsQuery.error as Error)
: priorResultsQuery.isError
? (priorResultsQuery.error as Error)
: null
}
podium={podium}
pole={pole}
isFirstTime={isFirstTimeCircuit}
/>
</div>
<TitleFightCard
loading={championshipQuery.isLoading}
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
drivers={titleFight}
sprintWeekend={sprintWeekend}
season={latestSeason}
/>
</div>
)
}

View File

@@ -6,17 +6,11 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = {
session_key?: number
}
type DriverProfileSearch = {
year?: number
}
const rootRoute = createRootRoute({
component: () => (
<>
@@ -70,32 +64,12 @@ export const championshipRoute = createRoute({
component: ChampionshipPage,
})
export const driverProfileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/drivers/$driverNumber',
validateSearch: (search: Record<string, unknown>): DriverProfileSearch => {
const year = Number(search.year)
return Number.isFinite(year) && year > 0 ? { year } : {}
},
component: function DriverProfileRoute() {
const { driverNumber } = driverProfileRoute.useParams()
const { year } = driverProfileRoute.useSearch()
return <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
},
})
export const briefingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/briefing',
component: BriefingPage,
})
export const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: RacePreviewPage,
})
const routeTree = rootRoute.addChildren([
commandCenterRoute,
raceHubRoute,
@@ -103,9 +77,7 @@ const routeTree = rootRoute.addChildren([
dataLibraryRoute,
liveTimingRoute,
championshipRoute,
driverProfileRoute,
briefingRoute,
previewRoute,
])
export const router = createRouter({ routeTree })

View File

@@ -808,12 +808,6 @@ a { color: inherit; text-decoration: none; }
color: var(--yellow);
}
.live-status-archive {
background: rgba(70, 140, 255, 0.08);
border: 1px solid rgba(70, 140, 255, 0.24);
color: #8bb7ff;
}
/* ── Live empty state ── */
.live-empty-status {
display: flex;
@@ -821,29 +815,6 @@ a { color: inherit; text-decoration: none; }
margin-bottom: var(--s4);
}
.live-archive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
margin-top: var(--s5);
min-height: 36px;
padding: 0 var(--s4);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: var(--text);
font-family: var(--f-mono);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.live-archive-btn:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.1);
}
.mono { font-family: var(--f-mono); }
.live-conn,

View File

@@ -1,208 +0,0 @@
.chapter-strip {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.chapter-strip-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
flex-wrap: wrap;
}
.chapter-strip-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.chapter-strip-actions {
display: flex;
align-items: center;
gap: var(--s2);
}
.chapter-tour-btn {
font-family: var(--f-mono);
font-size: 11px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: var(--s1);
background: var(--surface);
color: var(--text-2);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chapter-tour-btn:hover,
.chapter-tour-btn.active {
background: var(--surface-h);
border-color: var(--border-2);
color: var(--text);
}
.chapter-strip-scroll-wrap {
position: relative;
}
.chapter-strip-scroll-wrap::before,
.chapter-strip-scroll-wrap::after {
content: '';
position: absolute;
top: 0;
bottom: var(--s2);
width: 28px;
pointer-events: none;
z-index: 1;
}
.chapter-strip-scroll-wrap::before {
left: 0;
background: linear-gradient(to right, var(--bg), transparent);
}
.chapter-strip-scroll-wrap::after {
right: 0;
background: linear-gradient(to left, var(--bg), transparent);
}
.chapter-strip-scroll {
display: flex;
gap: var(--s3);
overflow-x: auto;
padding-bottom: var(--s2);
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
}
.chapter-strip-scroll::-webkit-scrollbar {
height: 4px;
}
.chapter-strip-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chapter-strip-scroll::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 999px;
}
.chapter-card {
flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s3) var(--s4);
border: 1px solid var(--border);
border-radius: var(--s2);
background: var(--surface);
text-align: left;
cursor: pointer;
scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.chapter-card:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.chapter-card.active {
border-color: var(--accent, var(--red));
background: var(--surface-h);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
}
.chapter-card.tour-active {
border-color: var(--yellow);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent);
}
.chapter-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
}
.chapter-kind {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 20px;
padding: 0 var(--s2);
border-radius: var(--s1);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: var(--bg);
color: var(--text-2);
border: 1px solid var(--border);
}
.chapter-card.active .chapter-kind {
color: var(--text);
border-color: var(--border-2);
}
.chapter-kind--safety_car,
.chapter-kind--virtual_safety_car {
color: var(--orange, #f90);
}
.chapter-kind--red_flag {
color: var(--red);
}
.chapter-kind--decisive_swing {
color: var(--green, #22c55e);
}
.chapter-kind--finish {
color: var(--yellow);
}
.chapter-lap-range {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
white-space: nowrap;
}
.chapter-headline {
font-size: 13px;
line-height: 1.35;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.chapter-strip-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.chapter-strip-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
@media (max-width: 900px) {
.chapter-strip-header {
align-items: flex-start;
}
}

View File

@@ -1,78 +0,0 @@
.compare-view {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.compare-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.compare-picker {
display: flex;
flex-direction: column;
gap: var(--s3);
min-width: 180px;
}
.compare-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.compare-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: var(--r1);
background: var(--surface-2);
color: var(--text);
}
.compare-picker-select:focus {
outline: 2px solid var(--gp-accent, var(--red));
outline-offset: 1px;
}
.compare-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.compare-section-title {
font-family: var(--f-mono);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-2);
}
.compare-section-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.compare-pit-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}
.compare-stale-notice {
font-size: 12px;
color: var(--amber, #f59e0b);
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--r1);
padding: var(--s2) var(--s3);
}

View File

@@ -1,76 +0,0 @@
.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;
}

View File

@@ -1,178 +0,0 @@
/* ── Calendar-aware paddock digest ── */
.digest-sticky {
position: sticky;
top: 0;
z-index: 20;
margin-bottom: var(--s5);
padding: var(--s4) var(--s5);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
}
.digest-sticky-head {
display: flex;
align-items: baseline;
gap: var(--s3);
flex-wrap: wrap;
margin-bottom: var(--s3);
}
.digest-sticky-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.digest-sticky-count {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
letter-spacing: 0.04em;
}
.digest-sticky-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
align-items: center;
}
.digest-tag-chip {
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text-2);
font-size: 11px;
font-family: var(--f-mono);
cursor: pointer;
transition: border-color 0.1s, color 0.1s, background 0.1s;
}
.digest-tag-chip:hover {
color: var(--text);
background: var(--surface-h);
}
.digest-tag-chip.active {
color: var(--text);
border-color: var(--tag-colour, var(--red));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
}
.digest-tag-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--tag-colour, var(--text-3));
flex-shrink: 0;
}
.digest-filter-clear {
padding: 2px 8px;
border: 1px dashed var(--border-2);
border-radius: 999px;
background: none;
color: var(--text-3);
font-size: 11px;
font-family: var(--f-mono);
cursor: pointer;
}
.digest-filter-clear:hover {
color: var(--text);
border-color: var(--border-2);
}
.digest-section {
margin-bottom: var(--s6);
}
.digest-section-head {
display: flex;
align-items: baseline;
gap: var(--s3);
margin-bottom: var(--s4);
padding-bottom: var(--s3);
border-bottom: 1px solid var(--border);
}
.digest-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0;
letter-spacing: 0.02em;
}
.digest-section-count {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.digest-recent-section .digest-section-title {
color: var(--text-2);
}
.bp-card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin-top: var(--s2);
}
.bp-card-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-2);
font-size: 10px;
font-family: var(--f-mono);
cursor: pointer;
}
.bp-card-tag:hover {
color: var(--text);
border-color: var(--border-2);
}
.bp-card-tag.active {
border-color: var(--tag-colour, var(--red));
}
.cc-brief-digest-meta {
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s3);
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.cc-brief-digest-tags {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
}
.cc-brief-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-2);
font-family: var(--f-mono);
}

View File

@@ -1,289 +0,0 @@
/* Driver profile page (/drivers/$driverNumber) */
.dp-page {
max-width: 1100px;
margin: 0 auto;
padding: var(--s6) var(--s5);
display: flex;
flex-direction: column;
gap: var(--s6);
}
/* --- header --- */
.dp-header {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--s5);
padding: var(--s5);
padding-left: var(--s6);
border: 1px solid var(--border);
border-left: 4px solid var(--red);
border-radius: var(--r2);
background: var(--surface);
}
.dp-title-row {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.dp-code {
font-size: 13px;
letter-spacing: 0.12em;
color: var(--text-2);
text-transform: uppercase;
}
.dp-num {
font-size: 13px;
font-weight: 700;
}
.dp-name {
margin: var(--s2) 0;
font-size: 30px;
line-height: 1.1;
letter-spacing: -0.01em;
}
.dp-team {
display: flex;
align-items: center;
gap: var(--s3);
font-size: 13px;
color: var(--text-2);
}
.dp-team-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
}
.dp-season {
color: var(--text-3);
}
.dp-stat-rail {
display: flex;
gap: var(--s5);
}
.dp-stat {
min-width: 72px;
}
.dp-stat-value {
font-size: 22px;
font-weight: 700;
}
.dp-stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
}
.dp-back {
position: absolute;
top: var(--s4);
right: var(--s5);
font-size: 12px;
color: var(--text-3);
text-decoration: none;
}
.dp-back:hover {
color: var(--text);
}
/* --- sections --- */
.dp-section {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.dp-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-2);
}
.dp-empty {
padding: var(--s5);
border: 1px dashed var(--border);
border-radius: var(--r2);
color: var(--text-3);
font-size: 13px;
}
/* --- charts --- */
.dp-form-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr);
gap: var(--s5);
align-items: stretch;
}
@media (max-width: 760px) {
.dp-form-grid {
grid-template-columns: 1fr;
}
}
.dp-chart {
margin: 0;
padding: var(--s4);
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
min-width: 0;
}
.dp-chart-caption {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
margin-bottom: var(--s3);
}
.dp-chart-svg {
display: block;
width: 100%;
height: auto;
}
.dp-chart-scroll {
overflow-x: auto;
}
.dp-gridline {
stroke: var(--border);
stroke-width: 1;
}
.dp-zeroline {
stroke: var(--text-3);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.dp-axis {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 10px;
}
.dp-delta-svg {
display: block;
}
.dp-delta-val {
fill: var(--text-2);
font-size: 10px;
}
/* --- form strip --- */
.dp-form-strip {
padding: var(--s4);
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
display: flex;
flex-direction: column;
}
.dp-form-bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: var(--s3);
min-height: 110px;
}
.dp-form-bar {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: var(--s2);
}
.dp-form-bar-fill {
width: 100%;
max-width: 34px;
border-radius: 2px 2px 0 0;
opacity: 0.9;
}
.dp-form-bar-val {
font-size: 11px;
color: var(--text-2);
}
/* --- rounds table --- */
.dp-scroll {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r2);
background: var(--surface);
}
.dp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.dp-table th {
padding: var(--s3) var(--s4);
font-family: var(--f-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
border-bottom: 1px solid var(--border);
}
.dp-table td {
padding: var(--s3) var(--s4);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.dp-table tbody tr:last-child td {
border-bottom: none;
}
.dp-table th.l,
.dp-table td.l {
text-align: left;
}
.dp-table th.r,
.dp-table td.r {
text-align: right;
}
.dp-td-dim {
color: var(--text-3);
}
.dp-flag {
margin-right: var(--s3);
}

View File

@@ -1,67 +0,0 @@
/* ── "What just happened" event rail (live page) ── */
.event-rail {
margin-top: var(--s5);
}
.event-rail-list {
display: flex;
flex-direction: column;
gap: var(--s3);
padding-top: var(--s4);
}
.event-rail-row {
display: flex;
align-items: flex-start;
gap: var(--s4);
padding: var(--s3) var(--s4);
border-left: 3px solid var(--border-2);
background: rgba(255, 255, 255, 0.03);
border-radius: 0 4px 4px 0;
}
.event-rail-icon {
display: inline-flex;
align-items: center;
color: var(--text-2);
padding-top: 1px;
flex-shrink: 0;
}
.event-kind-fastest-lap .event-rail-icon { color: var(--purple); }
.event-kind-personal-best .event-rail-icon { color: var(--green); }
.event-kind-overtake .event-rail-icon,
.event-kind-position-gain .event-rail-icon { color: var(--green); }
.event-kind-position-loss .event-rail-icon { color: var(--yellow); }
.event-kind-retirement .event-rail-icon,
.event-kind-track-status .event-rail-icon { color: var(--red); }
.event-kind-pit-in .event-rail-icon,
.event-kind-pit-out .event-rail-icon { color: var(--blue); }
.event-rail-body {
display: flex;
flex-direction: column;
gap: var(--s1);
min-width: 0;
flex: 1;
}
.event-rail-headline {
font-size: 12px;
color: var(--text);
line-height: 1.4;
}
.event-rail-detail {
font-size: 11px;
color: var(--text-2);
}
.event-rail-lap {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
flex-shrink: 0;
padding-top: 2px;
}

View File

@@ -1,130 +0,0 @@
/* Teammate head-to-head bars (issue #23). */
.h2h-section {
margin: 20px 0 24px;
padding: 16px 18px;
background: var(--surface-1, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border);
border-radius: 8px;
}
.h2h-section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.h2h-section-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-2);
}
.h2h-section-sub {
font-size: 11px;
font-family: var(--f-mono);
color: var(--text-3);
}
.h2h-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.h2h-row {
display: grid;
grid-template-columns: minmax(100px, 140px) 1fr;
align-items: center;
gap: 12px;
}
.h2h-team {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.h2h-team-name {
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.h2h-extra {
font-size: 10px;
color: var(--text-3);
flex-shrink: 0;
}
.h2h-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.h2h-tla {
font-size: 11px;
font-weight: 700;
color: var(--text);
flex-shrink: 0;
width: 28px;
}
.h2h-tla--left {
text-align: right;
}
.h2h-tla--right {
text-align: left;
}
.h2h-bar {
position: relative;
flex: 1;
display: flex;
height: 22px;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
}
.h2h-bar-seg {
height: 100%;
transition: width 0.2s ease;
}
.h2h-bar-seg--muted {
opacity: 0.35;
}
.h2h-score {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
@media (max-width: 640px) {
.h2h-row {
grid-template-columns: 1fr;
gap: 4px;
}
.h2h-bar-wrap {
width: 100%;
}
}

View File

@@ -1,373 +0,0 @@
.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;
}
}

View File

@@ -1,48 +0,0 @@
/* 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

@@ -1,309 +0,0 @@
.preview-page {
display: flex;
flex-direction: column;
gap: var(--s6);
padding: var(--s6);
max-width: 1200px;
margin: 0 auto;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s4);
min-height: 320px;
text-align: center;
padding: var(--s8);
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.preview-empty-eyebrow {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
}
.preview-empty-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.preview-empty-sub {
color: var(--text-2);
max-width: 36rem;
margin: 0;
}
.preview-header {
display: flex;
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
min-height: 140px;
}
.preview-header-accent {
width: 4px;
flex-shrink: 0;
background: var(--preview-accent, var(--gp-accent));
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
}
.preview-header-body {
flex: 1;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
min-width: 0;
}
.preview-header-top {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--s4);
}
.preview-gp-name {
font-size: 1.4rem;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: var(--s3);
}
.preview-gp-flag {
font-size: 1.5rem;
line-height: 1;
}
.preview-circuit {
color: var(--text-2);
font-size: 0.9rem;
margin: 0;
}
.preview-countdown {
text-align: right;
}
.preview-countdown-label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: var(--s1);
}
.preview-countdown-value {
font-family: var(--mono);
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--text-1);
}
.preview-schedule {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--s2);
}
.preview-schedule-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s2) var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
font-size: 0.8rem;
}
.preview-schedule-name {
font-weight: 600;
color: var(--text-1);
}
.preview-schedule-time {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-2);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--s5);
}
.preview-card {
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s4);
min-height: 200px;
}
.preview-card-title {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
margin: 0;
}
.preview-card-sub {
font-size: 0.85rem;
color: var(--text-2);
margin: calc(-1 * var(--s2)) 0 0;
}
.preview-track-stage {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 180px;
padding: var(--s4);
}
.preview-track-svg {
width: 100%;
max-width: 280px;
height: auto;
}
.preview-track-outline {
fill: none;
stroke: var(--preview-accent, var(--gp-accent));
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.9;
}
.preview-track-shadow {
fill: none;
stroke: rgba(0, 0, 0, 0.5);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview-podium {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.preview-podium-row {
display: flex;
align-items: center;
gap: var(--s3);
font-size: 0.9rem;
}
.preview-podium-pos {
width: 1.5rem;
font-family: var(--mono);
font-weight: 600;
color: var(--text-3);
}
.preview-podium-code {
font-weight: 600;
min-width: 2.5rem;
}
.preview-podium-team {
color: var(--text-2);
font-size: 0.8rem;
}
.preview-pole {
padding-top: var(--s3);
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.85rem;
color: var(--text-2);
}
.preview-pole strong {
color: var(--text-1);
}
.preview-title-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.preview-title-table th,
.preview-title-table td {
padding: var(--s2) var(--s3);
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.preview-title-table th {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
font-weight: 500;
}
.preview-title-table td.mono {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.preview-sprint-note {
font-size: 0.8rem;
color: var(--text-2);
margin: 0;
padding: var(--s3);
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.preview-section-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 0.85rem;
text-align: center;
padding: var(--s5);
}
.preview-section-state.error {
color: var(--red, #e74c3c);
}
@media (max-width: 640px) {
.preview-page {
padding: var(--s4);
}
.preview-header-top {
flex-direction: column;
}
.preview-countdown {
text-align: left;
}
}

View File

@@ -1,157 +0,0 @@
.race-story-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.race-story-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
.rs-replay-shell {
display: grid;
grid-template-columns: 1fr;
gap: var(--s5);
align-items: stretch;
}
.rs-replay-shell--split {
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
}
.rs-replay-main {
min-width: 0;
}
.rs-chart-container {
--rs-chart-max-width: 100%;
}
.rs-chart-container--full {
--rs-chart-max-width: 100%;
}
.rs-segmented-control {
display: inline-flex;
align-items: stretch;
flex-wrap: wrap;
gap: 0;
margin-top: var(--s3);
border: 1px solid var(--border-2);
border-radius: var(--s1);
background: var(--surface);
overflow: hidden;
}
.rs-segment {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 var(--s4);
font-family: var(--f-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-3);
background: transparent;
border: none;
border-right: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.rs-segment:last-child {
border-right: none;
}
.rs-segment:hover:not(:disabled) {
color: var(--text);
background: var(--surface-h);
}
.rs-segment:focus-visible {
outline: 2px solid var(--red);
outline-offset: -2px;
z-index: 1;
}
.rs-segment.active {
color: var(--text);
background: rgba(230, 36, 41, 0.12);
box-shadow: inset 0 -2px 0 var(--red);
}
.rs-segment:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.rs-segment-divider {
width: 1px;
align-self: stretch;
background: var(--border);
flex-shrink: 0;
}
.rs-replay-map-slot {
min-width: 0;
}
.rs-position-chart-svg {
width: 100%;
min-width: 280px;
max-width: var(--rs-chart-max-width, 640px);
display: block;
}
.rs-chart-axis-label {
fill: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.rs-chart-grid-line {
stroke: var(--border);
stroke-width: 0.5;
}
.rs-chart-lap-tick {
stroke: var(--border);
stroke-width: 1;
}
@media (max-width: 900px) {
.rs-replay-shell--split {
grid-template-columns: 1fr;
}
.rs-segmented-control {
width: 100%;
}
.rs-segment {
flex: 1 1 auto;
}
}
@media (max-width: 560px) {
.rs-segmented-control {
flex-direction: column;
align-items: stretch;
}
.rs-segment {
border-right: none;
border-bottom: 1px solid var(--border);
}
.rs-segment:last-child {
border-bottom: none;
}
.rs-segment-divider {
display: none;
}
}

View File

@@ -1,91 +0,0 @@
.replay-map-panel {
min-height: 244px;
height: 100%;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--s2);
}
.replay-map-stage {
position: relative;
min-height: 244px;
height: 100%;
}
.replay-map-svg {
width: 100%;
height: 100%;
min-height: 244px;
display: block;
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
}
.replay-map-outline-shadow,
.replay-map-outline {
fill: none;
vector-effect: non-scaling-stroke;
stroke-linecap: round;
stroke-linejoin: round;
}
.replay-map-outline-shadow {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 5;
}
.replay-map-outline {
stroke: var(--text-2);
stroke-width: 1.8;
}
.replay-car {
cursor: pointer;
outline: none;
}
.replay-car circle {
stroke: var(--bg);
stroke-width: 0.8;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
}
.replay-car text {
fill: #fff;
font-family: var(--f-mono);
font-size: 2.6px;
font-weight: 800;
text-anchor: middle;
pointer-events: none;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.75);
stroke-width: 0.7;
}
.replay-car:not(.replay-car-pinned) text {
opacity: 0;
}
.replay-car:hover text,
.replay-car:focus-visible text,
.replay-car-pinned text {
opacity: 1;
}
.replay-car-pinned circle {
stroke: #fff;
stroke-width: 1.2;
}
.replay-map-empty-card {
min-height: 244px;
display: grid;
place-content: center;
padding: var(--s6);
margin: 0;
}
.replay-map-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}

View File

@@ -1,134 +0,0 @@
.rivalry {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rivalry-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.rivalry-vs {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
padding-bottom: var(--s3);
}
.rivalry-picker {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 200px;
}
.rivalry-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.rivalry-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
color: var(--text);
}
.rivalry-picker-select:focus {
outline: 2px solid var(--red);
outline-offset: 1px;
}
.rivalry-h2h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s4) var(--s5);
padding: var(--s4) var(--s5);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
}
.rivalry-h2h-score {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.rivalry-h2h-code {
font-size: 14px;
font-weight: 700;
}
.rivalry-h2h-num {
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.rivalry-h2h-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.rivalry-strip {
display: flex;
align-items: center;
gap: var(--s2);
margin-left: auto;
}
.rivalry-strip-label {
font-family: var(--f-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
margin-right: var(--s2);
}
.rivalry-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
}
.rivalry-chip-round {
font-size: 9px;
color: var(--text-3);
}
.rivalry-chip-winner {
font-size: 11px;
font-weight: 700;
}
.rivalry-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rivalry-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}

View File

@@ -1,101 +0,0 @@
.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__pit-marker {
stroke: var(--text);
stroke-width: 1.5;
opacity: 0.7;
pointer-events: stroke;
}
.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);
}

View File

@@ -1,82 +0,0 @@
.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;
}

View File

@@ -1,101 +0,0 @@
.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;
}

View File

@@ -1,84 +0,0 @@
/* 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; }
}

View File

@@ -1,211 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BriefingPage } from '../pages/BriefingPage'
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
vi.mock('../api', () => ({
fetchNews: vi.fn(),
fetchNewsArticle: vi.fn(),
markNewsRead: vi.fn(),
fetchSeasons: vi.fn(),
fetchSeasonMeetings: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import {
fetchNews,
fetchNewsArticle,
fetchSeasons,
fetchSeasonMeetings,
fetchChampionshipHub,
markNewsRead,
} from '../api'
const mockFetchNews = vi.mocked(fetchNews)
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchHub = vi.mocked(fetchChampionshipHub)
const mockMarkNewsRead = vi.mocked(markNewsRead)
const bahrain: Meeting = {
meeting_key: 1,
meeting_name: 'Bahrain',
meeting_official_name: 'Bahrain GP',
location: 'Sakhir',
country_name: 'Bahrain',
country_code: 'BRN',
country_flag: '',
circuit_short_name: 'Sakhir',
date_start: '2025-03-14T00:00:00+00:00',
date_end: '2025-03-16T23:59:59+00:00',
year: 2025,
}
const monaco: Meeting = {
meeting_key: 2,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
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-25T23:59:59+00:00',
year: 2025,
}
const drivers: ChampHubDriver[] = [
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 3,
podiums: 5,
poles: 2,
form: [25, 18, 25],
cumulative: [25, 43, 68, 100],
teammate_wins: 4,
teammate_losses: 1,
round_positions: [],
},
{
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 80,
position: 2,
wins: 1,
podiums: 4,
poles: 1,
form: [18, 25, 18],
cumulative: [18, 36, 54, 80],
teammate_wins: 3,
teammate_losses: 2,
round_positions: [],
},
]
const teams: ChampHubTeam[] = [
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
]
const hub: ChampionshipHub = {
season: 2025,
round: 2,
total_rounds: 2,
rounds_left: 0,
last_race: 'Monaco GP',
round_labels: ['R1', 'R2'],
drivers,
teams,
}
const newsItems: NewsItem[] = [
{
source: 'autosport-f1',
title: 'Verstappen sets the pace in Bahrain',
url: 'https://example.com/bahrain-ver',
published_at: '2025-03-15T10:00:00Z',
fetched_at: '2025-03-15T11:00:00Z',
summary: 'Red Bull dominate opening practice',
},
{
source: 'racefans-f1',
title: 'Norris targets Monaco upgrade',
url: 'https://example.com/monaco-nor',
published_at: '2025-04-20T10:00:00Z',
fetched_at: '2025-04-20T11:00:00Z',
summary: 'McLaren bring new floor',
},
{
source: 'bbc-f1',
title: 'Undated paddock rumour',
url: 'https://example.com/undated',
fetched_at: '2025-04-21T11:00:00Z',
},
]
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<BriefingPage />
</QueryClientProvider>,
)
}
describe('BriefingPage digest layout', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
mockFetchSeasons.mockResolvedValue([2025])
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
mockFetchHub.mockResolvedValue(hub)
mockFetchNews.mockResolvedValue(newsItems)
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
mockMarkNewsRead.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
})
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
})
it('filters the digest when a tag chip is clicked', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
})
const sticky = screen.getByTestId('digest-sticky-header')
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
fireEvent.click(norTag)
await waitFor(() => {
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
})
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('digest-filter-clear'))
await waitFor(() => {
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
})
})
it('preserves category tabs', async () => {
renderPage()
await waitFor(() => {
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
})
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
})
})

View File

@@ -29,7 +29,6 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 4,
form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9,
teammate_losses: 1,
...over,
@@ -48,7 +47,6 @@ const drivers: ChampHubDriver[] = [
position: 2,
wins: 3,
cumulative: [18, 36, 54, 80, 120, 160],
round_positions: [2, 1, 2, 2, 2, 2],
}),
driver({
driver_number: 16,
@@ -60,7 +58,6 @@ const drivers: ChampHubDriver[] = [
position: 3,
wins: 1,
cumulative: [15, 28, 40, 60, 90, 120],
round_positions: [3, 3, 3, 0, 3, 3],
}),
]
@@ -115,7 +112,6 @@ 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 () => {
@@ -132,22 +128,6 @@ describe('ChampionshipPage', () => {
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
})
it('switches to the rivalry view with the top two drivers preselected', async () => {
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
// Default pair is the top two in the standings: VER vs NOR.
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER beats NOR in rounds 1, 3, 5, 6 → 42.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('42')
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
})
it('switches to the simulator view and projects standings', async () => {
renderPage()
@@ -169,61 +149,4 @@ describe('ChampionshipPage', () => {
})
expect(screen.getByText('No championship data')).toBeInTheDocument()
})
it('renders teammate battles section ordered by closest split', async () => {
const h2hDrivers: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
position: 2,
teammate_wins: 6,
teammate_losses: 5,
cumulative: [18, 36, 54, 80, 120, 160],
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
position: 3,
teammate_wins: 5,
teammate_losses: 6,
cumulative: [12, 28, 45, 70, 110, 140],
}),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 60,
position: 4,
teammate_wins: 1,
teammate_losses: 9,
cumulative: [5, 12, 20, 35, 50, 60],
}),
]
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
})
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
const rows = screen.getAllByTestId('teammate-h2h')
expect(rows).toHaveLength(2)
// McLaren 65 is closer than Red Bull 91 — McLaren row first.
expect(rows[0]).toHaveTextContent('McLaren')
expect(rows[0]).toHaveTextContent('65')
expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('91')
})
})

View File

@@ -17,7 +17,6 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0,
form: [],
cumulative: [],
round_positions: [],
teammate_wins: 0,
teammate_losses: 0,
...over,

View File

@@ -1,128 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChapterStrip } from '../components/ChapterStrip'
import type { Chapter } from '../types'
const chapters: Chapter[] = [
{
kind: 'start',
title: 'Start',
headline: 'Lights out — the field charges into Turn 1',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'safety_car',
title: 'Safety Car (L12-L15)',
headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits',
start_lap: 12,
end_lap: 15,
start_time: '2025-05-25T13:12:00Z',
end_time: '2025-05-25T13:15:00Z',
driver_numbers: [55],
},
]
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
const tRange = tMax - tMin
function renderStrip(
overrides: Partial<{
scrubTime: number | null
onChapterClick: (index: number, scrub: number) => void
}> = {},
) {
const onChapterClick = overrides.onChapterClick ?? vi.fn()
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={overrides.scrubTime ?? null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={onChapterClick}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
return { onChapterClick }
}
describe('ChapterStrip', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chapter headlines and lap ranges', () => {
renderStrip()
expect(screen.getByTestId('chapter-strip')).toBeInTheDocument()
expect(screen.getByText('Lights out — the field charges into Turn 1')).toBeInTheDocument()
expect(screen.getByText('L12L15')).toBeInTheDocument()
})
it('renders an empty-state card when there are no chapters', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={[]}
scrubTime={null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
})
it('highlights the active chapter from scrub time', () => {
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
renderStrip({ scrubTime: scrub })
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
it('calls click handler to jump scrubber', () => {
const onChapterClick = vi.fn<(index: number, scrub: number) => void>()
renderStrip({ onChapterClick })
fireEvent.click(screen.getByTestId('chapter-card-1'))
expect(onChapterClick).toHaveBeenCalledTimes(1)
const [index, scrub] = onChapterClick.mock.calls[0]
expect(index).toBe(1)
expect(scrub).toBeCloseTo(0.6, 2)
})
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={0}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
selectedChapterIndex={1}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
})

View File

@@ -12,20 +12,9 @@ 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,
fetchChampionshipHub,
fetchRaceHub,
} from '../api'
import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
@@ -33,8 +22,6 @@ 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,
@@ -115,49 +102,6 @@ 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: [],
chapters: [],
})
})
it('shows empty state when no seasons are ingested', async () => {
@@ -187,10 +131,11 @@ 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('hero-last-race-link')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
@@ -236,9 +181,9 @@ describe('CommandCenterPage', () => {
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
vi.useRealTimers()
})

View File

@@ -1,160 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DriverProfilePage } from '../pages/DriverProfilePage'
import type { DriverSummary } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchDriverSummary: vi.fn(),
}))
import { fetchSeasons, fetchDriverSummary } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchSummary = vi.mocked(fetchDriverSummary)
const summary: DriverSummary = {
season: 2025,
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
headshot_url: '',
points: 50,
position: 1,
wins: 2,
podiums: 2,
poles: 1,
form: [25, 25],
cumulative: [25, 50],
round_labels: ['R1', 'R2'],
rounds: [
{
meeting_key: 1201,
meeting_name: 'Bahrain Grand Prix',
country_code: 'BHR',
country_name: 'Bahrain',
race_position: 1,
grid_position: 1,
quali_position: 1,
points: 25,
dnf: false,
dns: false,
dsq: false,
},
{
meeting_key: 1202,
meeting_name: 'Saudi Arabian Grand Prix',
country_code: 'SAU',
country_name: 'Saudi Arabia',
race_position: 1,
grid_position: 4,
quali_position: 4,
points: 25,
dnf: false,
dns: false,
dsq: false,
},
],
}
function renderPage(props: { driverNumber: number; year?: number }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DriverProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchSummary.mockResolvedValue(summary)
})
it('renders header, form, quali-vs-race, and rounds sections', async () => {
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
// Header: identity + season stats.
expect(screen.getByTestId('dp-header')).toBeInTheDocument()
expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
expect(screen.getByText('#1')).toBeInTheDocument()
expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument()
// 'P1' / '50' also appear in the rounds table and chart axis.
expect(screen.getAllByText('P1').length).toBeGreaterThan(0)
expect(screen.getAllByText('50').length).toBeGreaterThan(0)
// Season form strip.
expect(screen.getByTestId('dp-form')).toBeInTheDocument()
expect(screen.getByText('Cumulative points')).toBeInTheDocument()
// Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3).
expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument()
expect(screen.getAllByText('+3').length).toBeGreaterThan(0)
// Track-by-track table rows with flags.
const rounds = screen.getByTestId('dp-rounds')
expect(rounds).toBeInTheDocument()
expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument()
expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument()
expect(rounds.textContent).toContain('🇧🇭')
expect(rounds.textContent).toContain('🇸🇦')
// With an explicit year, the seasons list is not needed.
expect(mockFetchSeasons).not.toHaveBeenCalled()
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
})
it('defaults to the latest season when no year is given', async () => {
renderPage({ driverNumber: 1 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
})
it('shows the API error message on failure', async () => {
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
renderPage({ driverNumber: 99, year: 2025 })
await waitFor(() => {
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
})
})
it('shows empty states when no rounds are completed', async () => {
mockFetchSummary.mockResolvedValue({
...summary,
form: [],
cumulative: [],
round_labels: [],
rounds: [],
})
renderPage({ driverNumber: 1, year: 2025 })
await waitFor(() => {
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
})
expect(screen.getAllByText('No completed rounds yet.').length).toBe(2)
expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument()
})
})

View File

@@ -189,26 +189,6 @@ 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(
<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', () => {
const sprintRows = Array.from({ length: 22 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),
@@ -221,7 +201,6 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
Path: '',
}}
/>,
)

View File

@@ -1,166 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiveTimingPage } from '../pages/LiveTimingPage'
import type { LiveStateResponse, LiveStreamData } from '../types'
vi.mock('../api', () => ({
fetchLiveState: vi.fn(),
fetchLiveTrackOutline: vi.fn(),
}))
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
const mockFetchLiveState = vi.mocked(fetchLiveState)
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
class MockEventSource {
onopen: (() => void) | null = null
onerror: (() => void) | null = null
constructor() {
setTimeout(() => this.onopen?.(), 0)
}
addEventListener() {}
close() {}
}
const archivedSnapshot: LiveStreamData = {
Drivers: {
'1': {
RacingNumber: '1',
Position: 1,
PrevPosition: 1,
GapToLeader: '',
Interval: '',
LastLapTime: '1:21.345',
LastLapPB: false,
LastLapOB: false,
BestLapTime: '1:20.987',
BestLapPB: false,
BestLapOB: false,
BestLapNum: 22,
InPit: false,
PitOut: false,
Retired: false,
KnockedOut: false,
Cutoff: false,
OnFlyingLap: false,
NumberOfLaps: 30,
SpeedTrap: '',
Sectors: [],
},
},
DriverInfo: {
'1': {
RacingNumber: '1',
BroadcastName: 'M VERSTAPPEN',
Tla: 'VER',
TeamName: 'Red Bull Racing',
TeamColour: '3671C6',
FirstName: 'Max',
LastName: 'Verstappen',
},
},
Tyres: {
'1': { Compound: 'HARD', New: false, Age: 12 },
},
Telemetry: {},
RCMessages: [],
Weather: {
AirTemp: 22,
TrackTemp: 41,
Humidity: 58,
WindSpeed: 3,
WindDir: 180,
Rainfall: false,
},
Session: {
MeetingName: 'Testonia Grand Prix',
CircuitName: 'Testring',
SessionType: 'Race',
SessionName: 'Race',
Path: '',
},
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
}
function renderPage(response: LiveStateResponse) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
mockFetchLiveState.mockResolvedValue(response)
mockFetchLiveTrackOutline.mockResolvedValue({
circuit_key: 1,
points: [],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
})
return render(
<QueryClientProvider client={queryClient}>
<LiveTimingPage />
</QueryClientProvider>,
)
}
describe('LiveTimingPage archive mode', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'EventSource', {
value: MockEventSource,
writable: true,
configurable: true,
})
})
it('keeps archived snapshots behind the View Last Session action', async () => {
renderPage({
is_live: false,
data: null,
last_snapshot: archivedSnapshot,
last_positions: {
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
},
last_snapshot_at: '2026-07-04T14:00:00Z',
})
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
await waitFor(() => {
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
})
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
expect(screen.getByText('archive')).toBeInTheDocument()
})
it('does not show the archive action when no snapshot is retained', async () => {
renderPage({ is_live: false, data: null })
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
})
it('temporarily omits the track map while live GPS is unavailable', async () => {
renderPage({
is_live: true,
data: { ...archivedSnapshot, SessionStatus: 'Started' },
})
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
})
})

View File

@@ -1,26 +0,0 @@
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

@@ -155,7 +155,6 @@ const raceHub: RaceHub = {
is_pit_out_lap: false,
},
],
chapters: [],
}
const weekend: Weekend = {

View File

@@ -1,277 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { RacePreviewPage } from '../pages/RacePreviewPage'
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
fetchChampionshipHub: vi.fn(),
}))
import {
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
fetchChampionshipHub,
} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchMeetings = vi.mocked(fetchMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchResults = vi.mocked(fetchResults)
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
const upcomingMeeting: Meeting = {
meeting_key: 100,
meeting_name: 'Monaco',
meeting_official_name: 'Monaco GP',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_key: 10,
circuit_short_name: 'Monaco',
date_start: '2099-05-22T00:00:00+00:00',
date_end: '2099-05-24T23:59:59+00:00',
year: 2099,
}
const priorMeeting: Meeting = {
...upcomingMeeting,
meeting_key: 90,
year: 2098,
date_start: '2098-05-22T00:00:00+00:00',
date_end: '2098-05-24T23:59:59+00:00',
}
const sessions: Session[] = [
{
session_key: 1,
session_name: 'FP1',
session_type: 'Practice',
meeting_key: 100,
date_start: '2099-05-22T10:00:00+00:00',
date_end: '2099-05-22T11:00:00+00:00',
gmt_offset: '02:00:00',
},
{
session_key: 2,
session_name: 'Sprint',
session_type: 'Sprint',
meeting_key: 100,
date_start: '2099-05-23T10:00:00+00:00',
date_end: '2099-05-23T11:00:00+00:00',
gmt_offset: '02:00:00',
},
{
session_key: 3,
session_name: 'Race',
session_type: 'Race',
meeting_key: 100,
date_start: '2099-05-24T13:00:00+00:00',
date_end: '2099-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
},
]
const priorRaceSession: Session = {
session_key: 50,
session_name: 'Race',
session_type: 'Race',
meeting_key: 90,
date_start: '2098-05-24T13:00:00+00:00',
date_end: '2098-05-24T15:00:00+00:00',
gmt_offset: '02:00:00',
}
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [25],
cumulative: [200],
round_positions: [1],
teammate_wins: 9,
teammate_losses: 1,
...over,
})
const hub: ChampionshipHub = {
season: 2099,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'Monaco GP',
round_labels: ['R1'],
drivers: [
hubDriver({}),
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
],
teams: [],
}
const results: EnrichedResult[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: 100,
gap_to_leader: 0,
number_of_laps: 78,
points: 25,
session_key: 50,
meeting_key: 90,
},
]
const grid: EnrichedGrid[] = [
{
driver_number: 4,
position: 1,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
session_key: 50,
meeting_key: 90,
lap_duration: 70,
},
]
const outline: TrackOutline = {
circuit_key: 10,
points: [
{ x: 0.1, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.9, y: 0.8 },
],
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
}
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<RacePreviewPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('RacePreviewPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSeasons.mockResolvedValue([2099])
mockFetchChampionshipHub.mockResolvedValue(hub)
mockFetchTrackOutline.mockResolvedValue(outline)
mockFetchResults.mockResolvedValue(results)
mockFetchStartingGrid.mockResolvedValue(grid)
})
it('renders upcoming race preview with sections', async () => {
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [upcomingMeeting]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
})
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
})
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
})
it('renders season-over state when no upcoming meeting', async () => {
mockFetchMeetings.mockResolvedValue([
{
...upcomingMeeting,
date_start: '2020-05-22T00:00:00+00:00',
date_end: '2020-05-24T23:59:59+00:00',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
})
expect(screen.getByText('Season complete')).toBeInTheDocument()
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
})
it('shows first-time circuit empty state', async () => {
const newCircuit: Meeting = {
...upcomingMeeting,
meeting_key: 200,
circuit_key: 999,
meeting_name: 'New GP',
}
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [newCircuit]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockResolvedValue(sessions)
mockFetchTrackOutline.mockResolvedValue(null)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
})
})

Some files were not shown because too many files have changed in this diff Show More