Compare commits
62 Commits
feat/issue
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a377d0bc4 | ||
|
|
3228e4adbd | ||
|
|
44a32bfd73 | ||
|
|
deda3bc4ad | ||
|
|
48030a6d7c | ||
|
|
74b33d1853 | ||
|
|
cefdac7006 | ||
|
|
7434621024 | ||
|
|
7be0b808fe | ||
|
|
26cd1857f6 | ||
|
|
8e0d0253c2 | ||
|
|
de614e6339 | ||
|
|
9ef8c7e9a3 | ||
|
|
dff187da92 | ||
|
|
9c0d37904c | ||
|
|
54ddc13f57 | ||
|
|
a1d8f85fd3 | ||
|
|
3b9b58ff7b | ||
|
|
480e6ca860 | ||
|
|
81deed4c75 | ||
|
|
d6d0558c72 | ||
|
|
b884ba8885 | ||
|
|
56ea860101 | ||
|
|
7c7886e6c6 | ||
|
|
71f81924ee | ||
|
|
7b86698b22 | ||
|
|
e88e0885ec | ||
|
|
255b296ecc | ||
|
|
888378e210 | ||
|
|
77a6b0f2dd | ||
|
|
d03e480e03 | ||
|
|
0b9c3fff0a | ||
|
|
adfa934f9d | ||
|
|
c475011c49 | ||
|
|
5a6323d3b4 | ||
|
|
ed3b8cf628 | ||
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 | ||
|
|
1dac73cdc5 | ||
|
|
02a84f67b4 | ||
|
|
f0a8e4e631 | ||
|
|
519053d340 | ||
|
|
e0d7b9981e | ||
|
|
79acf0579a | ||
|
|
82abbd6b35 | ||
|
|
faa173578c | ||
|
|
39f1560282 | ||
|
|
d3f7bab6dc | ||
|
|
5b112fb012 | ||
|
|
96d741424c | ||
|
|
c2617d39bb | ||
|
|
beacda278d | ||
|
|
f24ec4be94 | ||
|
|
09104451d0 | ||
|
|
f8bd183c26 | ||
|
|
01ec1f24e7 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 | ||
|
|
3cc6157337 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: groom
|
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]
|
argument-hint: <issue-number> [--lens architect]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: implement
|
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]
|
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: lens-architect
|
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>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: review
|
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]
|
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: write-spec
|
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>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -30,6 +30,7 @@ node_modules/
|
|||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
/release-fidelity/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/playwright/.auth/
|
||||||
|
|
||||||
|
|||||||
BIN
docs/phase-2/19-21-20-race-story.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/phase-2/22-driver-profile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/phase-2/23-teammate-h2h.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/phase-2/24-rivalry-compare.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/phase-2/25-race-preview.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/phase-2/26-briefing-digest.png
Normal file
|
After Width: | Height: | Size: 683 KiB |
32
docs/product/v0.4.0/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 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.
|
||||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -57,3 +57,58 @@ npm run test:visual:prod:update
|
|||||||
```
|
```
|
||||||
|
|
||||||
Snapshots live under `tests/visual/__snapshots__/`.
|
Snapshots live under `tests/visual/__snapshots__/`.
|
||||||
|
|
||||||
|
## Release Fidelity and Owner Review
|
||||||
|
|
||||||
|
Visual regression and mockup fidelity are deliberately separate gates. `npm run test:visual:prod` verifies the candidate against committed regression snapshots; it does not establish that the current UI matches the approved product design.
|
||||||
|
|
||||||
|
After the approved references are available at `docs/product/<version>/mockups/`, make an offline review packet. For v0.4.0 the packet pairs `weekend-between-races` and `weekend-live` at the 1280×800 desktop reference viewport, plus `weekend-between-sessions-mobile` at the 390×844 mobile reference viewport. The capture configuration uses the same seeded SQLite data and unreachable OpenF1 endpoint as the visual suite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RELEASE_FIDELITY_VERSION=v0.4.0
|
||||||
|
npm run release:fidelity:capture
|
||||||
|
npm run release:fidelity:packet
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `release-fidelity/v0.4.0/index.html` and review every approved-mockup/candidate pair. The owner, not an automated tool, records a decision in the committed file `docs/release/owner-reviews/v0.4.0.md`:
|
||||||
|
|
||||||
|
```md
|
||||||
|
# Owner Fidelity Sign-off: v0.4.0
|
||||||
|
|
||||||
|
- Version: v0.4.0
|
||||||
|
- Candidate commit: <full commit SHA>
|
||||||
|
- Reviewed by: <owner name>
|
||||||
|
- Reviewed on: YYYY-MM-DD
|
||||||
|
- Decision: approved
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not create the file or use `approved` until the owner has reviewed the packet. Once it is committed, the release gate can verify the evidence and decision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:fidelity:verify
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/<version>.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval.
|
||||||
|
|
||||||
|
For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off.
|
||||||
|
|
||||||
|
## Deployment and Rollback
|
||||||
|
|
||||||
|
Build and preserve a SHA-256 record with the deployable binary. Verify the staged binary before replacing the running one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p dist
|
||||||
|
go build -trimpath -o dist/box-box ./cmd/main.go
|
||||||
|
sha256sum dist/box-box | tee dist/box-box.sha256
|
||||||
|
sha256sum -c dist/box-box.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the current production binary and its SHA before deployment. If owner review rejects the release or deployment fails, restore that saved binary, then verify the restored SHA is byte-identical to the pre-deployment record:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum /srv/box-box/box-box
|
||||||
|
install -m 0755 /srv/box-box/backups/box-box.previous /srv/box-box/box-box
|
||||||
|
sha256sum -c /srv/box-box/backups/box-box.previous.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart and health-check the service using the deployment environment's normal procedure. Keep the candidate SHA, prior SHA, fidelity packet path, and owner sign-off path with the release record.
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import type {
|
|||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
|
DriverSummary,
|
||||||
|
EnrichedGrid,
|
||||||
|
EnrichedResult,
|
||||||
LapsComparisonResponse,
|
LapsComparisonResponse,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
LiveSessionMeta,
|
LiveSessionMeta,
|
||||||
Meeting,
|
Meeting,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
RaceHub,
|
RaceHub,
|
||||||
|
ReplayFramesResponse,
|
||||||
Session,
|
Session,
|
||||||
TrackOutline,
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
|
WeekendContext,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
@@ -40,7 +45,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
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}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
}
|
}
|
||||||
@@ -48,6 +57,47 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
|||||||
return Array.isArray(meetings) ? meetings : []
|
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[]> {
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -65,6 +115,14 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
||||||
|
const res = await fetch('/api/v1/weekend-context')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||||
const params = new URLSearchParams({ source: 'auto' })
|
const params = new URLSearchParams({ source: 'auto' })
|
||||||
if (year) params.set('year', year.toString())
|
if (year) params.set('year', year.toString())
|
||||||
@@ -76,6 +134,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
|
|||||||
return res.json()
|
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> {
|
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||||
const res = await fetch('/api/v1/live/state')
|
const res = await fetch('/api/v1/live/state')
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
116
frontend/src/components/ChapterStrip.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/EmptyStateCard.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchLiveState } from '../api'
|
||||||
|
import { isLiveSessionActive } from '../lib/live'
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
|
const { data: liveState } = useQuery({
|
||||||
|
queryKey: ['live-state'],
|
||||||
|
queryFn: fetchLiveState,
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
const liveActive = isLiveSessionActive(liveState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="app-nav">
|
<nav className="app-nav">
|
||||||
<Link to="/" className="nav-logo">
|
<Link to="/" className="nav-logo">
|
||||||
@@ -10,12 +21,23 @@ export function Nav() {
|
|||||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||||
Command
|
Command
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
<Link
|
||||||
|
to="/live"
|
||||||
|
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
|
||||||
|
activeProps={{ className: 'active' }}
|
||||||
|
data-testid="nav-live"
|
||||||
|
data-live-active={liveActive ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
|
||||||
Live
|
Live
|
||||||
|
{liveActive && <span className="sr-only"> session active</span>}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||||
|
Preview
|
||||||
|
</Link>
|
||||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||||
Championship
|
Championship
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchNews } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
} from '../lib/digest'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
const SOURCE_DISPLAY: Record<string, string> = {
|
const SOURCE_DISPLAY: Record<string, string> = {
|
||||||
'fia': 'FIA',
|
'fia': 'FIA',
|
||||||
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PaddockBriefing() {
|
export function PaddockBriefing() {
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: () => fetchNews(100),
|
||||||
staleTime: 60_000,
|
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 unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||||
const preview = news?.slice(0, 5) ?? []
|
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||||
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>}
|
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
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 { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
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 {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -12,38 +42,256 @@ interface Props {
|
|||||||
race_control?: any[]
|
race_control?: any[]
|
||||||
pit_stops?: any[]
|
pit_stops?: any[]
|
||||||
session?: Session
|
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) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
const {
|
||||||
|
results,
|
||||||
|
starting_grid: grid,
|
||||||
|
positions,
|
||||||
|
datasets,
|
||||||
|
race_control = [],
|
||||||
|
pit_stops = [],
|
||||||
|
laps = [],
|
||||||
|
session,
|
||||||
|
meeting,
|
||||||
|
drivers = [],
|
||||||
|
chapters = [],
|
||||||
|
} = data
|
||||||
const hasPositions = datasets['positions']?.status === 'available'
|
const hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
const [hoverDriver, setHoverDriver] = 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 svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
const hasChartData = hasPositions && chartTiming !== null
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
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])
|
||||||
|
|
||||||
let chartContent = null
|
let chartContent = null
|
||||||
let displayResults = results
|
let displayResults = results
|
||||||
|
|
||||||
if (hasChartData) {
|
if (hasChartData && chartTiming) {
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
const { tMin, tRange } = chartTiming
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||||
const tRange = Math.max(tMax - tMin, 1)
|
|
||||||
|
|
||||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
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, [])
|
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||||
byDriver.get(p.driver_number)!.push({
|
byDriver.get(p.driver_number)!.push({
|
||||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
t: normaliseTime(time),
|
||||||
pos: p.position,
|
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()) {
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
samples.sort((a, b) => a.t - b.t)
|
samples.sort((a, b) => a.t - b.t)
|
||||||
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
||||||
@@ -51,7 +299,7 @@ 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 (!samples || samples.length === 0) return null
|
||||||
if (t <= samples[0].t) return samples[0].pos
|
if (t <= samples[0].t) return samples[0].pos
|
||||||
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||||
@@ -84,34 +332,37 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
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 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 acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
||||||
|
const positionLabels = decimatedPositionLabels(maxPos)
|
||||||
|
|
||||||
const W = 640
|
const W = CHART_W
|
||||||
const H = 180
|
const H = CHART_H
|
||||||
const PL = 40
|
const PL = CHART_PL
|
||||||
const PR = 48
|
const PR = CHART_PR
|
||||||
const PT = 8
|
const PT = CHART_PT
|
||||||
const PB = 20
|
const PB = CHART_PB
|
||||||
const plotW = W - PL - PR
|
const plotW = W - PL - PR
|
||||||
const plotH = H - PT - PB
|
const plotH = H - PT - PB
|
||||||
|
|
||||||
const toX = (t: number) => PL + t * plotW
|
const toX = (t: number) => PL + t * plotW
|
||||||
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
||||||
|
|
||||||
const winner = results.find(r => r.position === 1)
|
const winner = results.find((r) => r.position === 1)
|
||||||
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
|
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
|
||||||
const lapTicks: { lap: number, t: number }[] = []
|
const lapTicks: { lap: number; t: number }[] = []
|
||||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||||
|
|
||||||
for (const lap of winnerLaps) {
|
for (let i = 0; i < winnerLaps.length; i++) {
|
||||||
|
const lap = winnerLaps[i]
|
||||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
const lapStart = new Date(lap.date_start).getTime()
|
||||||
if (t >= 0 && t <= 1) {
|
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||||
lapTicks.push({ lap: lap.lap_number, t })
|
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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety Car / VSC periods
|
|
||||||
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||||
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
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())
|
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
@@ -136,23 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
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>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
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 t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
setScrubTime(t)
|
setScrubTime(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
chartContent = (
|
chartContent = (
|
||||||
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
<div
|
||||||
|
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
|
||||||
|
data-testid="position-chart"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
|
className="rs-position-chart-svg"
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Position evolution chart"
|
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) => {
|
{scPeriods.map((sc, i) => {
|
||||||
const startT = (sc.start - tMin) / tRange
|
const startT = (sc.start - tMin) / tRange
|
||||||
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||||
@@ -171,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
{positionLabels.map((pos) => (
|
||||||
<g key={pos}>
|
<g key={pos}>
|
||||||
<line
|
<line
|
||||||
x1={PL}
|
x1={PL}
|
||||||
x2={W - PR}
|
x2={W - PR}
|
||||||
y1={toY(pos)}
|
y1={toY(pos)}
|
||||||
y2={toY(pos)}
|
y2={toY(pos)}
|
||||||
stroke="var(--border)"
|
className="rs-chart-grid-line"
|
||||||
strokeWidth={0.5}
|
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={PL - 4}
|
x={PL - 4}
|
||||||
y={toY(pos) + 4}
|
y={toY(pos) + 4}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
fill="var(--text-3)"
|
className="rs-chart-axis-label"
|
||||||
fontSize={8}
|
|
||||||
fontFamily="var(--f-mono)"
|
|
||||||
>
|
>
|
||||||
P{pos}
|
P{pos}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{lapTicks.map(tick => (
|
{lapTicks.map((tick) => (
|
||||||
<g key={`lap-${tick.lap}`}>
|
<g key={`lap-${tick.lap}`}>
|
||||||
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
|
<line
|
||||||
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
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"
|
||||||
|
>
|
||||||
L{tick.lap}
|
L{tick.lap}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -210,8 +519,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const last = samples[samples.length - 1]
|
const last = samples[samples.length - 1]
|
||||||
const isHovered = hoverDriver === dNum
|
const isHovered = hoverDriver === dNum
|
||||||
const isFaded = hoverDriver !== null && !isHovered
|
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 (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -232,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{driverPits.map((p, i) => {
|
{driverPits.map((p, i) => {
|
||||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
const time = new Date(p.date).getTime()
|
||||||
if (t < 0 || t > 1) return null
|
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||||
|
const t = normaliseTime(time)
|
||||||
const pos = getInterpPos(samples, t)
|
const pos = getInterpPos(samples, t)
|
||||||
if (pos === null) return null
|
if (pos === null) return null
|
||||||
return (
|
return (
|
||||||
@@ -253,7 +564,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
{last && (
|
{last && (
|
||||||
<text
|
<text
|
||||||
x={toX(last.t) + 6}
|
x={toX(last.t) + 6}
|
||||||
y={toY(last.pos) + 4}
|
y={labelY + 4}
|
||||||
fill={color}
|
fill={color}
|
||||||
fontSize={isHovered ? 11 : 9}
|
fontSize={isHovered ? 11 : 9}
|
||||||
fontFamily="var(--f-mono)"
|
fontFamily="var(--f-mono)"
|
||||||
@@ -287,25 +598,110 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
width={plotW}
|
width={plotW}
|
||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
|
data-testid="position-chart-interaction"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => setScrubTime(null)}
|
onPointerLeave={() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
clearChapterSelection()
|
||||||
|
setScrubTime(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="race-story-canvas">
|
<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 ? (
|
{hasChartData ? (
|
||||||
chartContent
|
chartContent
|
||||||
) : (
|
) : (
|
||||||
<div className="analysis-notice">
|
<EmptyStateCard
|
||||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
icon={LineChart}
|
||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div className="rs-field-list">
|
||||||
@@ -313,7 +709,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
const isWinner = i === 0 && r.position === 1
|
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
|
let currentPoints: number | string = r.points
|
||||||
if (scrubTime !== null) {
|
if (scrubTime !== null) {
|
||||||
@@ -330,9 +727,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
onMouseLeave={() => setHoverDriver(null)}
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
>
|
>
|
||||||
<div className="rs-driver-left">
|
<div className="rs-driver-left">
|
||||||
<div className={`rs-pos-col ${pClass}`}>
|
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
|
||||||
{currentPos}
|
|
||||||
</div>
|
|
||||||
<div className="rs-driver-cell">
|
<div className="rs-driver-cell">
|
||||||
<div
|
<div
|
||||||
className="rs-driver-color"
|
className="rs-driver-color"
|
||||||
|
|||||||
159
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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}`
|
||||||
|
}
|
||||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
frontend/src/components/TeammateH2H.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-switcher" data-testid="rh-switcher">
|
<div id="rh-weekend-switcher" className="rh-switcher" data-testid="rh-switcher">
|
||||||
<div className="rh-switcher-head">
|
<div className="rh-switcher-head">
|
||||||
<span className="sec-title">Switch Weekend</span>
|
<span className="sec-title">Switch Weekend</span>
|
||||||
<div className="rh-switcher-years">
|
<div className="rh-switcher-years">
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
<section className="live-rc panel-glass">
|
<section className="live-rc panel-glass">
|
||||||
<div className="sec-header sticky-header">
|
<div className="sec-header sticky-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<span className="sec-title">Race Control</span>
|
||||||
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
|
<span className="sec-meta" data-testid="rc-timezone">
|
||||||
|
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{latest.length === 0 ? (
|
{latest.length === 0 ? (
|
||||||
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
||||||
@@ -102,7 +104,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`live-rc-row${flashClass}`} key={key}>
|
<div className={`live-rc-row${flashClass}`} key={key}>
|
||||||
<span className="rc-time">{message.Time || '--:--'}</span>
|
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
|
||||||
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
||||||
{message.Flag
|
{message.Flag
|
||||||
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
|
|||||||
<div className="live-banner-meta">
|
<div className="live-banner-meta">
|
||||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
{atRiskLabel && <span>{atRiskLabel}</span>}
|
||||||
<span>
|
{display.isRace && (
|
||||||
|
<span data-testid="live-lap-counter">
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, Fragment } from 'react'
|
import { useState, useMemo, Fragment } from 'react'
|
||||||
import { teamColor } from '../../utils'
|
import { teamColor } from '../../utils'
|
||||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import {
|
import {
|
||||||
|
bestLapGaps,
|
||||||
driverCode,
|
driverCode,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
positionDelta,
|
positionDelta,
|
||||||
@@ -62,6 +63,10 @@ export function TimingTower({
|
|||||||
const isQuali = sessionDisplay.isQualifying || !isRace
|
const isQuali = sessionDisplay.isQualifying || !isRace
|
||||||
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
||||||
|
|
||||||
|
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
|
||||||
|
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
|
||||||
|
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scroll-x">
|
<div className="scroll-x">
|
||||||
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
||||||
@@ -71,7 +76,7 @@ export function TimingTower({
|
|||||||
{isRace && <th>Δ</th>}
|
{isRace && <th>Δ</th>}
|
||||||
<th>Driver</th>
|
<th>Driver</th>
|
||||||
<th>Tyre</th>
|
<th>Tyre</th>
|
||||||
<th>Last Lap</th>
|
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
|
||||||
<th
|
<th
|
||||||
className="interactive"
|
className="interactive"
|
||||||
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
||||||
@@ -81,10 +86,10 @@ export function TimingTower({
|
|||||||
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
||||||
</th>
|
</th>
|
||||||
{isRace && <th>Trend</th>}
|
{isRace && <th>Trend</th>}
|
||||||
{isQuali && <th>S1</th>}
|
{isQuali && <th className="hide-mobile">S1</th>}
|
||||||
{isQuali && <th>S2</th>}
|
{isQuali && <th className="hide-mobile">S2</th>}
|
||||||
{isQuali && <th>S3</th>}
|
{isQuali && <th className="hide-mobile">S3</th>}
|
||||||
<th className="hide-mobile">Best</th>
|
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
|
||||||
{isRace && <th className="hide-mobile r">Laps</th>}
|
{isRace && <th className="hide-mobile r">Laps</th>}
|
||||||
<th className="r"></th>
|
<th className="r"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,7 +107,15 @@ export function TimingTower({
|
|||||||
driver.Cutoff
|
driver.Cutoff
|
||||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||||
|
|
||||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
let gapText: string
|
||||||
|
if (isRace) {
|
||||||
|
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||||
|
} else if (driver.GapToLeader) {
|
||||||
|
gapText = driver.GapToLeader
|
||||||
|
} else {
|
||||||
|
const computed = practiceGaps[row.RacingNumber]
|
||||||
|
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
|
||||||
|
}
|
||||||
const intervalAnnotation =
|
const intervalAnnotation =
|
||||||
gapMode === 'interval' && isRace && row.Position > 1
|
gapMode === 'interval' && isRace && row.Position > 1
|
||||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||||
@@ -153,7 +166,10 @@ export function TimingTower({
|
|||||||
<td>
|
<td>
|
||||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
<td className={[
|
||||||
|
isRace ? '' : 'hide-mobile',
|
||||||
|
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
|
||||||
|
].filter(Boolean).join(' ')}>
|
||||||
{driver.LastLapTime || '-'}
|
{driver.LastLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">
|
<td className="mono">
|
||||||
@@ -171,11 +187,14 @@ export function TimingTower({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isQuali && <td>{renderSector(0)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
|
||||||
{isQuali && <td>{renderSector(1)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
|
||||||
{isQuali && <td>{renderSector(2)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
|
||||||
|
|
||||||
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
|
<td className={[
|
||||||
|
isRace ? 'hide-mobile' : '',
|
||||||
|
driver.BestLapOB ? 'mono lap-ob' : 'mono',
|
||||||
|
].filter(Boolean).join(' ')}>
|
||||||
{driver.BestLapTime || '-'}
|
{driver.BestLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -208,6 +227,16 @@ export function TimingTower({
|
|||||||
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
|
||||||
|
<div data-testid="expanded-sectors">
|
||||||
|
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
|
||||||
|
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
{renderSector(0)}
|
||||||
|
{renderSector(1)}
|
||||||
|
{renderSector(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{driver.SpeedTrap && (
|
{driver.SpeedTrap && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const isRace = isRaceSession(sessionType)
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
|
const [readerChose, setReaderChose] = useState(false)
|
||||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||||
@@ -61,8 +63,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
||||||
const isRace = isRaceSession(sessionType)
|
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rows.filter(
|
rows.filter(
|
||||||
@@ -74,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
[rows, pinned],
|
[rows, pinned],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One linear fit per driver per snapshot, shared by the readiness check and
|
||||||
|
// the rows below. degradationModel is O(laps) and this runs at feed rate.
|
||||||
|
const models = useMemo(() => {
|
||||||
|
const out: Record<string, ReturnType<typeof degradationModel>> = {}
|
||||||
|
for (const row of visible) {
|
||||||
|
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [visible, stints])
|
||||||
|
|
||||||
|
// Before any stint has enough clean laps to fit, every row reads "warming
|
||||||
|
// up" — a full-height panel of placeholders that pushed the Timing Tower off
|
||||||
|
// the fold for the first third of a race. Stay collapsed until there is
|
||||||
|
// something to say, then open. A reader who has toggled it keeps their choice.
|
||||||
|
const hasSignal = useMemo(
|
||||||
|
() => visible.some((row) => models[row.RacingNumber] != null),
|
||||||
|
[visible, models],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerChose) return
|
||||||
|
setCollapsed(!(isRace && hasSignal))
|
||||||
|
}, [isRace, hasSignal, readerChose])
|
||||||
|
|
||||||
if (visible.length === 0) return null
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sec-header tyredeg-toggle"
|
className="sec-header tyredeg-toggle"
|
||||||
onClick={() => setCollapsed((prev) => !prev)}
|
onClick={() => {
|
||||||
|
setReaderChose(true)
|
||||||
|
setCollapsed((prev) => !prev)
|
||||||
|
}}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
>
|
>
|
||||||
<span className="sec-title">Tyre Deg & Pit Window</span>
|
<span className="sec-title">Tyre Deg & Pit Window</span>
|
||||||
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
{!hasSignal ? (
|
||||||
|
<span className="sec-meta">collecting clean laps</span>
|
||||||
|
) : (
|
||||||
|
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
|
||||||
|
)}
|
||||||
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="tyredeg-rows">
|
<div className="tyredeg-rows">
|
||||||
{visible.map((row) => {
|
{visible.map((row) => {
|
||||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
const model = models[row.RacingNumber]
|
||||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||||
return (
|
return (
|
||||||
|
|||||||
132
frontend/src/lib/chapters.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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 (0–1) 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 (0–1) 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 0–1 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]))
|
||||||
|
}
|
||||||
293
frontend/src/lib/digest.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
69
frontend/src/lib/driverProfile.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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 '='
|
||||||
|
}
|
||||||
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
|
|||||||
return name.slice(0, 3).toUpperCase() || '—'
|
return name.slice(0, 3).toUpperCase() || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countryFlag(meeting: Meeting | undefined | null): string {
|
export function countryFlag(
|
||||||
|
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
|
||||||
|
): string {
|
||||||
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
||||||
const code = meeting?.country_code?.toUpperCase()
|
const code = meeting?.country_code?.toUpperCase()
|
||||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||||
|
|||||||
47
frontend/src/lib/h2h.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
|
|||||||
return row.Info?.Tla || row.RacingNumber
|
return row.Info?.Tla || row.RacingNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
|
||||||
|
* Returns null for empty/invalid values so callers never invent a gap.
|
||||||
|
*/
|
||||||
|
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
|
||||||
|
if (!value) return null
|
||||||
|
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
|
||||||
|
if (!match) return null
|
||||||
|
const minutes = match[1] ? Number(match[1]) : 0
|
||||||
|
const seconds = Number(match[2])
|
||||||
|
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
|
||||||
|
return minutes * 60 + seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestLapGap {
|
||||||
|
isLeader: boolean
|
||||||
|
gap: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
|
||||||
|
* valid best lap. Only drivers with a parseable best lap get an entry, and the
|
||||||
|
* fastest is flagged as the leader. Never fabricates a gap from a missing or
|
||||||
|
* invalid lap — the upstream interval remains the source of truth for races.
|
||||||
|
*/
|
||||||
|
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
|
||||||
|
let leaderNumber = ''
|
||||||
|
let best = Infinity
|
||||||
|
for (const row of rows) {
|
||||||
|
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
||||||
|
if (seconds === null) continue
|
||||||
|
if (seconds < best) {
|
||||||
|
best = seconds
|
||||||
|
leaderNumber = row.RacingNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, BestLapGap> = {}
|
||||||
|
if (!Number.isFinite(best)) return out
|
||||||
|
for (const row of rows) {
|
||||||
|
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
||||||
|
if (seconds === null) continue
|
||||||
|
out[row.RacingNumber] =
|
||||||
|
row.RacingNumber === leaderNumber
|
||||||
|
? { isLeader: true, gap: '' }
|
||||||
|
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the live feed reports an in-progress session with timing data. */
|
||||||
|
export function isLiveSessionActive(
|
||||||
|
state: { is_live?: boolean; data?: unknown } | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(state?.is_live && state.data)
|
||||||
|
}
|
||||||
|
|
||||||
export function trackStatusLabel(status: string): string {
|
export function trackStatusLabel(status: string): string {
|
||||||
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
||||||
}
|
}
|
||||||
|
|||||||
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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)}`
|
||||||
|
}
|
||||||
75
frontend/src/lib/replay.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
14
frontend/src/lib/replayMap.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
61
frontend/src/lib/rivalry.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -145,6 +145,15 @@ export function formatSessionScheduleTime(value: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MAX_BROWSER_TIMEOUT = 2_147_483_647
|
||||||
|
|
||||||
|
export function refreshDeadlineDelay(refreshAt: string | undefined, now = Date.now()): number | null {
|
||||||
|
if (!refreshAt) return null
|
||||||
|
const deadline = Date.parse(refreshAt)
|
||||||
|
if (Number.isNaN(deadline)) return null
|
||||||
|
return Math.min(Math.max(0, deadline - now), MAX_BROWSER_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
|
export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
|
||||||
|
|
||||||
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {
|
export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
|
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 { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
|
|
||||||
const PAGE_SIZE = 16
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<Category, string> = {
|
const CATEGORY_LABELS: Record<Category, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
official: 'Official',
|
official: 'Official',
|
||||||
@@ -71,6 +91,31 @@ 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 }) {
|
function OGImage({ url, title }: { url?: string; title: string }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const initial = (title[0] ?? '?').toUpperCase()
|
const initial = (title[0] ?? '?').toUpperCase()
|
||||||
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
|
|||||||
function BriefingCard({
|
function BriefingCard({
|
||||||
item,
|
item,
|
||||||
isActive,
|
isActive,
|
||||||
|
activeTag,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onTagClick,
|
||||||
}: {
|
}: {
|
||||||
item: NewsItem
|
item: TaggedNewsItem
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
activeTag: string | null
|
||||||
onSelect: (item: NewsItem) => void
|
onSelect: (item: NewsItem) => void
|
||||||
|
onTagClick: (tag: DigestTag) => void
|
||||||
}) {
|
}) {
|
||||||
const isRead = !!item.read_at
|
const isRead = !!item.read_at
|
||||||
const isVideo = categoryOf(item) === 'video'
|
const isVideo = categoryOf(item) === 'video'
|
||||||
@@ -129,6 +178,25 @@ function BriefingCard({
|
|||||||
{stripHtml(item.og_description || item.summary || '')}
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -146,7 +214,6 @@ function ReaderPanel({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -155,7 +222,6 @@ function ReaderPanel({
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
// Fetch article when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
setArticle(null)
|
setArticle(null)
|
||||||
@@ -175,9 +241,14 @@ function ReaderPanel({
|
|||||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||||
}, [item?.url, item ? categoryOf(item) : ''])
|
}, [item?.url, item ? categoryOf(item) : ''])
|
||||||
|
|
||||||
// Scroll panel to top when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
panelRef.current?.scrollTo({ top: 0 })
|
const panel = panelRef.current
|
||||||
|
if (!panel) return
|
||||||
|
if (typeof panel.scrollTo === 'function') {
|
||||||
|
panel.scrollTo({ top: 0 })
|
||||||
|
} else {
|
||||||
|
panel.scrollTop = 0
|
||||||
|
}
|
||||||
}, [item?.url])
|
}, [item?.url])
|
||||||
|
|
||||||
const isOpen = item !== null
|
const isOpen = item !== null
|
||||||
@@ -297,7 +368,6 @@ function ReaderPanel({
|
|||||||
{article && !loading && article.content && (
|
{article && !loading && article.content && (
|
||||||
<div
|
<div
|
||||||
className="bp-reader-body"
|
className="bp-reader-body"
|
||||||
// readability strips scripts/iframes; sources are all known news outlets
|
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||||
/>
|
/>
|
||||||
@@ -332,8 +402,9 @@ function ReaderPanel({
|
|||||||
export function BriefingPage() {
|
export function BriefingPage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
@@ -341,13 +412,74 @@ export function BriefingPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = allNews.filter(
|
const seasonsQuery = useQuery({
|
||||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
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 pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
||||||
const safePage = Math.min(page, pageCount)
|
const categoryFiltered = useMemo(
|
||||||
const pageStart = (safePage - 1) * PAGE_SIZE
|
() => taggedNews.filter(
|
||||||
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
(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 counts: Record<Category, number> = {
|
const counts: Record<Category, number> = {
|
||||||
all: allNews.length,
|
all: allNews.length,
|
||||||
@@ -374,22 +506,14 @@ export function BriefingPage() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTagClick = useCallback((tag: DigestTag) => {
|
||||||
setPage((current) => Math.min(current, pageCount))
|
setActiveTag((current) => (current === tag.key ? null : tag.key))
|
||||||
}, [pageCount])
|
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
|
||||||
(nextPage: number) => {
|
|
||||||
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
}, [])
|
||||||
},
|
|
||||||
[pageCount],
|
|
||||||
)
|
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||||
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
const hasDigest = meetings.length > 0
|
||||||
const showPagination = filtered.length > PAGE_SIZE
|
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
@@ -408,74 +532,106 @@ export function BriefingPage() {
|
|||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
onChange={(c) => {
|
||||||
|
setActiveCategory(c)
|
||||||
|
setSelectedItem(null)
|
||||||
|
setActiveTag(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{hasDigest && (
|
||||||
<div className="bp-empty">
|
<div className="digest-sticky" data-testid="digest-sticky-header">
|
||||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
<div className="digest-sticky-head">
|
||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
||||||
</div>
|
<span className="digest-sticky-count">
|
||||||
) : (
|
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
||||||
<>
|
|
||||||
{showPagination && (
|
|
||||||
<div className="bp-pagination bp-pagination-top">
|
|
||||||
<span className="bp-pagination-meta mono">
|
|
||||||
{pageStart + 1}-{pageEnd} of {filtered.length}
|
|
||||||
</span>
|
</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>
|
||||||
|
{(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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<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.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
{pagedItems.map((item) => (
|
{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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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
|
<BriefingCard
|
||||||
key={item.url}
|
key={item.url}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={selectedItem?.url === item.url}
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||||
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
import { Meaning } from '../components/Meaning'
|
import { Meaning } from '../components/Meaning'
|
||||||
|
import { TeammateH2H } from '../components/TeammateH2H'
|
||||||
|
import { teammatePairs } from '../lib/h2h'
|
||||||
import { pointsGapMeaning } from '../lib/meaning'
|
import { pointsGapMeaning } from '../lib/meaning'
|
||||||
|
|
||||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||||
|
|
||||||
const GOLD = '#ffd700'
|
const GOLD = '#ffd700'
|
||||||
const SILVER = '#c0c0c0'
|
const SILVER = '#c0c0c0'
|
||||||
@@ -213,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
>
|
>
|
||||||
Progression
|
Progression
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setView('rivalry')}
|
||||||
|
data-testid="champ-tab-rivalry"
|
||||||
|
>
|
||||||
|
Rivalry
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||||||
@@ -242,10 +254,13 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
leaderPoints={leader.points}
|
leaderPoints={leader.points}
|
||||||
titleMath={titleMath}
|
titleMath={titleMath}
|
||||||
roundsLeft={hub.rounds_left}
|
roundsLeft={hub.rounds_left}
|
||||||
|
drivers={drivers}
|
||||||
|
season={hub.season}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||||
|
{view === 'rivalry' && <RivalryCompare hub={hub} />}
|
||||||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -271,13 +286,19 @@ function DriversView({
|
|||||||
leaderPoints,
|
leaderPoints,
|
||||||
titleMath,
|
titleMath,
|
||||||
roundsLeft,
|
roundsLeft,
|
||||||
|
drivers,
|
||||||
|
season,
|
||||||
}: {
|
}: {
|
||||||
enriched: EnrichedDriver[]
|
enriched: EnrichedDriver[]
|
||||||
leaderPoints: number
|
leaderPoints: number
|
||||||
titleMath: string
|
titleMath: string
|
||||||
roundsLeft: number
|
roundsLeft: number
|
||||||
|
drivers: ChampHubDriver[]
|
||||||
|
season: number
|
||||||
}) {
|
}) {
|
||||||
const podium = enriched.slice(0, 3)
|
const podium = enriched.slice(0, 3)
|
||||||
|
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="champ-view-drivers">
|
<div data-testid="champ-view-drivers">
|
||||||
<div className="champ-podium">
|
<div className="champ-podium">
|
||||||
@@ -336,6 +357,29 @@ function DriversView({
|
|||||||
<span className="champ-titlemath-text">{titleMath}</span>
|
<span className="champ-titlemath-text">{titleMath}</span>
|
||||||
</div>
|
</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">
|
<div className="champ-scroll">
|
||||||
<table className="champ-table champ-table-drivers">
|
<table className="champ-table champ-table-drivers">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -360,12 +404,19 @@ function DriversView({
|
|||||||
P{e.pos}
|
P{e.pos}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="champ-drv">
|
<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}`}
|
||||||
|
>
|
||||||
<span className="champ-drv-bar" style={{ background: e.color }} />
|
<span className="champ-drv-bar" style={{ background: e.color }} />
|
||||||
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
<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-name">{e.d.full_name}</span>
|
||||||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||||||
</div>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="champ-td-team">{e.d.team_name}</td>
|
<td className="champ-td-team">{e.d.team_name}</td>
|
||||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
currentAndNextSession,
|
currentAndNextSession,
|
||||||
focusMeetingKind,
|
focusMeetingKind,
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
|
meetingEndTime,
|
||||||
meetingHasStarted,
|
meetingHasStarted,
|
||||||
mostRecentPastMeeting,
|
mostRecentPastMeeting,
|
||||||
nextUpcomingMeeting,
|
nextUpcomingMeeting,
|
||||||
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
|
|||||||
) as WeekendSession['datasets']
|
) as WeekendSession['datasets']
|
||||||
|
|
||||||
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
||||||
|
const end = meetingEndTime(meeting)
|
||||||
|
if (end && now >= end) return 'past'
|
||||||
if (meeting.meeting_key === focusKey) return 'focus'
|
if (meeting.meeting_key === focusKey) return 'focus'
|
||||||
if (meetingHasStarted(meeting, now)) return 'past'
|
if (meetingHasStarted(meeting, now)) return 'past'
|
||||||
return 'future'
|
return 'future'
|
||||||
@@ -163,6 +166,12 @@ export function CommandCenterPage() {
|
|||||||
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
||||||
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
||||||
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
||||||
|
const railSessions =
|
||||||
|
focusWeekendSessions.length > 0
|
||||||
|
? focusWeekendSessions
|
||||||
|
: heroStateKind === 'between'
|
||||||
|
? lastPastWeekend?.sessions ?? []
|
||||||
|
: []
|
||||||
|
|
||||||
const lastRaceHubQuery = useQuery({
|
const lastRaceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
||||||
@@ -178,6 +187,16 @@ export function CommandCenterPage() {
|
|||||||
|
|
||||||
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
||||||
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
||||||
|
const focusNarrative =
|
||||||
|
heroStateKind === 'between'
|
||||||
|
? lastRaceName
|
||||||
|
? `${lastRaceName} is in the archive. The next chapter begins at ${focusMeeting?.circuit_short_name || focusMeeting?.meeting_name || 'the next round'}.`
|
||||||
|
: 'The season is between race weekends. Use the timeline to revisit a completed round or look ahead.'
|
||||||
|
: currentSession
|
||||||
|
? `${currentSession.session_name} is the active chapter of this weekend. Session detail remains available as it lands locally.`
|
||||||
|
: nextSession
|
||||||
|
? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.`
|
||||||
|
: 'The weekend timetable is ready to explore.'
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading command center…</div>
|
return <div className="page loading-state">loading command center…</div>
|
||||||
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
|
|||||||
<div className="cc-topbar">
|
<div className="cc-topbar">
|
||||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||||
<span className="cc-topbar-meta mono">
|
<span className="cc-topbar-meta mono">
|
||||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
{latestSeason} season · weekend desk
|
||||||
</span>
|
</span>
|
||||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||||
@@ -262,6 +281,26 @@ export function CommandCenterPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{focusMeeting && (
|
||||||
|
<section className="cc-context" data-testid="cc-circuit-context">
|
||||||
|
<div className="cc-context-decal mono" aria-hidden="true">
|
||||||
|
{countryDecal(focusMeeting)}
|
||||||
|
</div>
|
||||||
|
<div className="cc-context-copy">
|
||||||
|
<span className="cc-context-kicker mono">Circuit context</span>
|
||||||
|
<h2>{focusMeeting.circuit_short_name || focusMeeting.meeting_name}</h2>
|
||||||
|
<p>{focusNarrative}</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-context-meta mono">
|
||||||
|
<span>{focusMeeting.location || focusMeeting.country_name}</span>
|
||||||
|
<span>{formatGpDateRange(focusMeeting)}</span>
|
||||||
|
<Link to="/preview" className="cc-context-preview">
|
||||||
|
Weekend preview →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="cc-dashboard-grid">
|
<div className="cc-dashboard-grid">
|
||||||
<div className="cc-dashboard-main">
|
<div className="cc-dashboard-main">
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
|
|||||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
<div className="cc-calendar-accent" aria-hidden="true" />
|
||||||
<div className="cc-calendar-top mono">
|
<div className="cc-calendar-top mono">
|
||||||
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
||||||
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
|
<span className={`cc-calendar-status cc-calendar-status-${status}`}>
|
||||||
|
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cc-calendar-id">
|
<div className="cc-calendar-id">
|
||||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
||||||
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{focusWeekendSessions.length > 0 && (
|
{railSessions.length > 0 && (
|
||||||
<section className="cc-schedule" data-testid="cc-schedule">
|
<section className="cc-schedule" data-testid="cc-schedule">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Weekend Schedule</span>
|
<span className="sec-title">Weekend Schedule</span>
|
||||||
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
|
<span className="sec-meta mono">{railSessions.length} sessions</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cc-session-strip" role="list">
|
<div className="cc-session-strip" role="list">
|
||||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
{railSessions.map(({ session, source, datasets }) => {
|
||||||
const status = classifySessionStatus(session, nowDate)
|
const status = classifySessionStatus(session, nowDate)
|
||||||
const isNext = nextSession?.session_key === session.session_key
|
const isNext = nextSession?.session_key === session.session_key
|
||||||
const isCurrent = currentSession?.session_key === session.session_key
|
const isCurrent = currentSession?.session_key === session.session_key
|
||||||
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="cc-session-name">{session.session_name}</div>
|
<div className="cc-session-name">{session.session_name}</div>
|
||||||
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
||||||
<div className="cc-session-cov mono">
|
<div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
|
||||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||||
{formatCoverageHint(datasets)}
|
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PaddockBriefing />
|
<PaddockBriefing />
|
||||||
|
<div className="cc-data-note mono" data-testid="cc-data-note">
|
||||||
|
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
|
||||||
|
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
369
frontend/src/pages/DriverProfilePage.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -289,14 +289,16 @@ export function LiveTimingPage() {
|
|||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Rail runs most-synthesized to most-raw: a reader arriving
|
||||||
|
mid-session wants "what did I miss" before the regulatory log. */}
|
||||||
<div className="live-rc-col">
|
<div className="live-rc-col">
|
||||||
|
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||||
<TeamRadioTicker
|
<TeamRadioTicker
|
||||||
captures={snapshot.TeamRadio ?? []}
|
captures={snapshot.TeamRadio ?? []}
|
||||||
driverInfo={snapshot.DriverInfo}
|
driverInfo={snapshot.DriverInfo}
|
||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
||||||
fetchLocalMeetings,
|
|
||||||
fetchRaceHub,
|
|
||||||
fetchSeasons,
|
|
||||||
fetchWeekend,
|
|
||||||
} from '../api'
|
|
||||||
import { DatasetStrip } from '../components/DatasetStrip'
|
import { DatasetStrip } from '../components/DatasetStrip'
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
import { TabBar, type Tab } from '../components/TabBar'
|
import { TabBar, type Tab } from '../components/TabBar'
|
||||||
@@ -22,76 +17,72 @@ import { SourceBadge } from '../components/SourceBadge'
|
|||||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||||
import {
|
import {
|
||||||
|
MAX_BROWSER_TIMEOUT,
|
||||||
|
formatCountdown,
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
pickFocusMeeting,
|
refreshDeadlineDelay,
|
||||||
sortSessionsByStart,
|
sortSessionsByStart,
|
||||||
} from '../lib/schedule'
|
} from '../lib/schedule'
|
||||||
import type { Weekend, WeekendSession } from '../types'
|
import type { ContextSession, Weekend } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
|
||||||
if (!weekend) return undefined
|
|
||||||
const local = weekend.sessions.filter((s) => s.source === 'local')
|
|
||||||
const partial = weekend.sessions.filter((s) => s.source === 'partial')
|
|
||||||
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
|
|
||||||
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
|
||||||
if (race) return race
|
|
||||||
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
|
||||||
if (qual) return qual
|
|
||||||
return pool[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RaceHubPage({ sessionKey }: Props) {
|
export function RaceHubPage({ sessionKey }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const [refreshGeneration, setRefreshGeneration] = useState(0)
|
||||||
|
|
||||||
// ─── Auto-redirect when no session_key is supplied ───
|
// The server owns bare Race Hub selection so every open tab crosses the
|
||||||
const seasonsQuery = useQuery({
|
// one-hour handoff at the same instant.
|
||||||
queryKey: ['seasons'],
|
const contextQuery = useQuery({
|
||||||
queryFn: fetchSeasons,
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: fetchWeekendContext,
|
||||||
enabled: sessionKey === 0,
|
enabled: sessionKey === 0,
|
||||||
})
|
})
|
||||||
|
const { refetch: refetchContext } = contextQuery
|
||||||
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
const context = contextQuery.data
|
||||||
|
const preSession = sessionKey === 0 && context?.race_hub_pre_session === true
|
||||||
const meetingsQuery = useQuery({
|
const preSessionRef = context?.race_hub_default_session
|
||||||
queryKey: ['meetings', latestSeason],
|
const preSessionMeetingKey = preSessionRef?.meeting?.meeting_key
|
||||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
const preSessionWeekendQuery = useQuery({
|
||||||
enabled: sessionKey === 0 && latestSeason != null,
|
queryKey: ['weekend', preSessionMeetingKey],
|
||||||
})
|
queryFn: () => fetchWeekend(preSessionMeetingKey!),
|
||||||
|
enabled: preSession && preSessionMeetingKey != null && preSessionMeetingKey > 0,
|
||||||
const focusMeeting = useMemo(() => {
|
|
||||||
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
|
||||||
return pickFocusMeeting(meetingsQuery.data, new Date())
|
|
||||||
}, [sessionKey, meetingsQuery.data])
|
|
||||||
|
|
||||||
const fallbackWeekendQuery = useQuery({
|
|
||||||
queryKey: ['weekend', focusMeeting?.meeting_key],
|
|
||||||
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
|
|
||||||
enabled: sessionKey === 0 && focusMeeting != null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionKey !== 0) return
|
if (sessionKey !== 0) return
|
||||||
const weekend = fallbackWeekendQuery.data
|
const delay = refreshDeadlineDelay(context?.race_hub_refresh_at)
|
||||||
if (!weekend) return
|
if (delay == null) return
|
||||||
const target = pickAnalysisSession(weekend)?.session.session_key
|
const rearmAfterRefetch = delay === MAX_BROWSER_TIMEOUT
|
||||||
?? weekend.default_session_key
|
const timer = window.setTimeout(() => {
|
||||||
?? weekend.sessions[0]?.session.session_key
|
void refetchContext().finally(() => {
|
||||||
if (target) {
|
if (rearmAfterRefetch) setRefreshGeneration((generation) => generation + 1)
|
||||||
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
})
|
||||||
}
|
}, delay)
|
||||||
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [sessionKey, context?.race_hub_refresh_at, refetchContext, refreshGeneration])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preSession) return
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [preSession])
|
||||||
|
|
||||||
|
// A bare route retains canonical context ownership while rendering its
|
||||||
|
// completed analysis selection. Explicit URLs remain user-owned.
|
||||||
|
const selectedSessionKey = sessionKey || context?.race_hub_default_session?.session.session_key || 0
|
||||||
|
|
||||||
// ─── Active session payload ───
|
// ─── Active session payload ───
|
||||||
const raceHubQuery = useQuery({
|
const raceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', sessionKey],
|
queryKey: ['race-hub', selectedSessionKey],
|
||||||
queryFn: () => fetchRaceHub(sessionKey),
|
queryFn: () => fetchRaceHub(selectedSessionKey),
|
||||||
enabled: sessionKey > 0,
|
enabled: selectedSessionKey > 0 && (sessionKey > 0 || !preSession),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,25 +99,36 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const accent = countryAccent(data?.meeting ?? null)
|
const accent = countryAccent(data?.meeting ?? null)
|
||||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
// ─── No session_key: resolve exclusively through canonical Weekend Context ───
|
||||||
if (sessionKey === 0) {
|
if (sessionKey === 0) {
|
||||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
if (contextQuery.isLoading || (preSession && preSessionWeekendQuery.isLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
<div className="loading-state">resolving latest local weekend…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const seasons = seasonsQuery.data ?? []
|
if (preSession && preSessionRef) {
|
||||||
if (seasons.length === 0) {
|
return (
|
||||||
|
<RaceHubPreSession
|
||||||
|
session={preSessionRef}
|
||||||
|
weekend={preSessionWeekendQuery.data}
|
||||||
|
now={now}
|
||||||
|
switcherOpen={switcherOpen}
|
||||||
|
onToggleSwitcher={() => setSwitcherOpen((open) => !open)}
|
||||||
|
onCloseSwitcher={() => setSwitcherOpen(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!selectedSessionKey) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
||||||
<div className="rh-empty-band">
|
<div className="rh-empty-band">
|
||||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
<h1 className="rh-empty-title">No local sessions yet</h1>
|
<h1 className="rh-empty-title">No completed local analysis yet</h1>
|
||||||
<p className="rh-empty-sub">
|
<p className="rh-empty-sub">
|
||||||
The Race Hub reads from local ingest only. Once a weekend is ingested
|
Race Hub opens completed local analysis between weekends. Check Data Health
|
||||||
it will open here automatically.
|
to ingest a completed session.
|
||||||
</p>
|
</p>
|
||||||
<div className="rh-empty-actions">
|
<div className="rh-empty-actions">
|
||||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
||||||
@@ -136,18 +138,13 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<div className="rh-page" style={accentStyle}>
|
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Loading / error for the requested session_key ───
|
// ─── Loading / error for the selected session ───
|
||||||
if (raceHubQuery.isLoading) {
|
if (raceHubQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">loading session {sessionKey}…</div>
|
<div className="loading-state">loading session {selectedSessionKey}…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,7 +154,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<div className="error-box">
|
<div className="error-box">
|
||||||
{raceHubQuery.error instanceof Error
|
{raceHubQuery.error instanceof Error
|
||||||
? raceHubQuery.error.message
|
? raceHubQuery.error.message
|
||||||
: `Failed to load session ${sessionKey}.`}
|
: `Failed to load session ${selectedSessionKey}.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -168,7 +165,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const sessionMeta = weekend
|
const sessionMeta = weekend
|
||||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||||
: {}
|
: {}
|
||||||
const activeSessionMeta = sessionMeta[sessionKey]
|
const activeSessionMeta = sessionMeta[selectedSessionKey]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
@@ -194,7 +191,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
{switcherOpen && (
|
{switcherOpen && (
|
||||||
<WeekendSwitcher
|
<WeekendSwitcher
|
||||||
currentMeetingKey={meetingKey}
|
currentMeetingKey={meetingKey}
|
||||||
currentSessionKey={sessionKey}
|
currentSessionKey={selectedSessionKey}
|
||||||
onClose={() => setSwitcherOpen(false)}
|
onClose={() => setSwitcherOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -225,7 +222,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const meta = sessionMeta[session.session_key]
|
const meta = sessionMeta[session.session_key]
|
||||||
const active = session.session_key === sessionKey
|
const active = session.session_key === selectedSessionKey
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -278,7 +275,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
<span className="rh-active-key mono">key {selectedSessionKey}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -314,7 +311,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="sec-title">Driver Compare</span>
|
<span className="sec-title">Driver Compare</span>
|
||||||
</div>
|
</div>
|
||||||
<CompareView
|
<CompareView
|
||||||
sessionKey={sessionKey}
|
sessionKey={selectedSessionKey}
|
||||||
results={data.results}
|
results={data.results}
|
||||||
drivers={data.drivers}
|
drivers={data.drivers}
|
||||||
/>
|
/>
|
||||||
@@ -368,3 +365,72 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RaceHubPreSession({
|
||||||
|
session,
|
||||||
|
weekend,
|
||||||
|
now,
|
||||||
|
switcherOpen,
|
||||||
|
onToggleSwitcher,
|
||||||
|
onCloseSwitcher,
|
||||||
|
}: {
|
||||||
|
session: ContextSession
|
||||||
|
weekend?: Weekend
|
||||||
|
now: number
|
||||||
|
switcherOpen: boolean
|
||||||
|
onToggleSwitcher: () => void
|
||||||
|
onCloseSwitcher: () => void
|
||||||
|
}) {
|
||||||
|
const meeting = session.meeting
|
||||||
|
const sessions = sortSessionsByStart((weekend?.sessions ?? []).map((entry) => entry.session))
|
||||||
|
const target = new Date(session.session.date_start)
|
||||||
|
const accent = countryAccent(meeting ?? null)
|
||||||
|
const pendingLiveEvidence = target.getTime() <= now
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-page rh-empty" data-testid="race-hub-pre-session" style={{ '--gp-accent': accent } as React.CSSProperties}>
|
||||||
|
<div className="rh-topbar">
|
||||||
|
<span className="rh-topbar-label mono">box-box · race hub</span>
|
||||||
|
<span className="rh-topbar-spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
|
||||||
|
onClick={onToggleSwitcher}
|
||||||
|
aria-expanded={switcherOpen}
|
||||||
|
aria-controls="rh-weekend-switcher"
|
||||||
|
data-testid="rh-switch-weekend"
|
||||||
|
>
|
||||||
|
{switcherOpen ? 'Close' : 'Switch Weekend'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{switcherOpen && (
|
||||||
|
<WeekendSwitcher
|
||||||
|
currentMeetingKey={meeting?.meeting_key}
|
||||||
|
currentSessionKey={session.session.session_key}
|
||||||
|
onClose={onCloseSwitcher}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="rh-empty-band">
|
||||||
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
|
<h1 className="rh-empty-title">{meeting?.meeting_name ?? 'Next race weekend'}</h1>
|
||||||
|
<p className="rh-empty-sub">
|
||||||
|
{pendingLiveEvidence
|
||||||
|
? `${session.session.session_name} is scheduled; awaiting live timing.`
|
||||||
|
: <>{session.session.session_name} begins in <span className="mono">{formatCountdown(target, new Date(now))}</span></>}
|
||||||
|
</p>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="rh-pre-session-schedule">
|
||||||
|
{sessions.map((scheduled) => (
|
||||||
|
<div key={scheduled.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{scheduled.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">{formatSessionScheduleTime(scheduled.date_start)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
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' 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,11 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
|||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
|
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||||
|
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DriverProfileSearch = {
|
||||||
|
year?: number
|
||||||
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
<>
|
<>
|
||||||
@@ -64,12 +70,32 @@ export const championshipRoute = createRoute({
|
|||||||
component: ChampionshipPage,
|
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({
|
export const briefingRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/briefing',
|
path: '/briefing',
|
||||||
component: BriefingPage,
|
component: BriefingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const previewRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/preview',
|
||||||
|
component: RacePreviewPage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
commandCenterRoute,
|
commandCenterRoute,
|
||||||
raceHubRoute,
|
raceHubRoute,
|
||||||
@@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([
|
|||||||
dataLibraryRoute,
|
dataLibraryRoute,
|
||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
championshipRoute,
|
championshipRoute,
|
||||||
|
driverProfileRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
|
previewRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.app-nav::-webkit-scrollbar { display: none; }
|
.app-nav::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
|
||||||
|
where the links stop fitting, fade the trailing edge so the cut-off item
|
||||||
|
reads as "scroll for more" instead of as a clipping bug. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.app-nav {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -112,6 +122,30 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
||||||
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* Live nav gets a pulsing marker only while a session is on air. */
|
||||||
|
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
|
||||||
|
.nav-live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
|
||||||
|
animation: pulse-live 1.6s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-utility {
|
.nav-utility {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4730,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: #f82f34;
|
background: #f82f34;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weekend recut: put the active circuit and season journey ahead of data plumbing. */
|
||||||
|
.cc-context {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(84px, 0.3fr) minmax(0, 1fr) auto;
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: center;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: var(--s5) var(--s6);
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--gp-accent) 9%, transparent), transparent 46%),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-decal {
|
||||||
|
color: color-mix(in srgb, var(--gp-accent) 58%, var(--text));
|
||||||
|
font-size: clamp(38px, 6vw, 72px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
line-height: 0.75;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-copy h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-kicker {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--s2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-copy p {
|
||||||
|
max-width: 64ch;
|
||||||
|
margin-top: var(--s2);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--s2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-context-preview {
|
||||||
|
margin-top: var(--s2);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cc-context-preview:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.cc-calendar-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: var(--s3) 0 var(--s4);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-calendar-card {
|
||||||
|
min-width: 112px;
|
||||||
|
min-height: 138px;
|
||||||
|
flex: 1 0 112px;
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border-radius: 0;
|
||||||
|
border-width: 1px 1px 1px 0;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.cc-calendar-card:first-child { border-left-width: 1px; }
|
||||||
|
.cc-calendar-card:hover { transform: none; box-shadow: inset 0 -2px 0 var(--gp-card-accent); }
|
||||||
|
.cc-calendar-card::before { display: none; }
|
||||||
|
.cc-calendar-accent { top: auto; bottom: 0; height: 2px; opacity: 0.65; }
|
||||||
|
.cc-calendar-past { filter: none; opacity: 0.62; }
|
||||||
|
.cc-calendar-past:hover { filter: none; opacity: 1; }
|
||||||
|
.cc-calendar-focus { background: color-mix(in srgb, var(--gp-card-accent) 10%, transparent); }
|
||||||
|
.cc-calendar-decal { font-size: 60px; bottom: -5px; }
|
||||||
|
.cc-calendar-status {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.cc-calendar-status-focus { color: var(--gp-card-accent); }
|
||||||
|
.cc-calendar-status-future { color: var(--text-2); }
|
||||||
|
|
||||||
|
.cc-data-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding-top: var(--s4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.cc-dashboard-grid { gap: var(--s5); }
|
||||||
|
.cc-dashboard-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); }
|
||||||
|
.cc-briefing, .cc-data-note { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cc-context { grid-template-columns: 1fr auto; gap: var(--s4); padding: var(--s4); }
|
||||||
|
.cc-context-decal { display: none; }
|
||||||
|
.cc-context-meta { align-items: flex-start; grid-column: 1 / -1; text-align: left; }
|
||||||
|
.cc-context-copy h2 { font-size: 17px; }
|
||||||
|
.cc-context-copy p { font-size: 11px; }
|
||||||
|
.cc-dashboard-sidebar { display: flex; }
|
||||||
|
.cc-calendar-grid { margin: 0 calc(var(--s4) * -1); padding-left: var(--s4); padding-right: var(--s4); }
|
||||||
|
.cc-calendar-card { min-width: 104px; flex-basis: 104px; }
|
||||||
|
.cc-calendar-circuit { display: none; }
|
||||||
|
.cc-champ-snapshot { order: 2; }
|
||||||
|
.cc-schedule { order: 1; }
|
||||||
|
.cc-briefing { order: 3; margin-top: var(--s4); }
|
||||||
|
.cc-data-note { order: 4; }
|
||||||
|
}
|
||||||
|
|||||||
208
frontend/src/styles/chapters.css
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
frontend/src/styles/digest.css
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/* ── 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);
|
||||||
|
}
|
||||||
289
frontend/src/styles/driver-profile.css
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/* 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);
|
||||||
|
}
|
||||||
130
frontend/src/styles/h2h.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
309
frontend/src/styles/preview.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
frontend/src/styles/race-story.css
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
frontend/src/styles/replay-map.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
134
frontend/src/styles/rivalry.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
211
frontend/src/test/BriefingPage.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25, 18, 25, 15, 25],
|
form: [25, 18, 25, 15, 25],
|
||||||
cumulative: [25, 43, 68, 83, 108, 200],
|
cumulative: [25, 43, 68, 83, 108, 200],
|
||||||
|
round_positions: [1, 2, 1, 3, 1, 1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
position: 2,
|
position: 2,
|
||||||
wins: 3,
|
wins: 3,
|
||||||
cumulative: [18, 36, 54, 80, 120, 160],
|
cumulative: [18, 36, 54, 80, 120, 160],
|
||||||
|
round_positions: [2, 1, 2, 2, 2, 2],
|
||||||
}),
|
}),
|
||||||
driver({
|
driver({
|
||||||
driver_number: 16,
|
driver_number: 16,
|
||||||
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
position: 3,
|
position: 3,
|
||||||
wins: 1,
|
wins: 1,
|
||||||
cumulative: [15, 28, 40, 60, 90, 120],
|
cumulative: [15, 28, 40, 60, 90, 120],
|
||||||
|
round_positions: [3, 3, 3, 0, 3, 3],
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
|
|||||||
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
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 → 4–2.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('4–2')
|
||||||
|
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('switches to the simulator view and projects standings', async () => {
|
it('switches to the simulator view and projects standings', async () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
@@ -150,4 +169,61 @@ describe('ChampionshipPage', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByText('No championship data')).toBeInTheDocument()
|
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 6–5 is closer than Red Bull 9–1 — McLaren row first.
|
||||||
|
expect(rows[0]).toHaveTextContent('McLaren')
|
||||||
|
expect(rows[0]).toHaveTextContent('6–5')
|
||||||
|
expect(rows[1]).toHaveTextContent('Red Bull')
|
||||||
|
expect(rows[1]).toHaveTextContent('9–1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 0,
|
poles: 0,
|
||||||
form: [],
|
form: [],
|
||||||
cumulative: [],
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
teammate_wins: 0,
|
teammate_wins: 0,
|
||||||
teammate_losses: 0,
|
teammate_losses: 0,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
128
frontend/src/test/ChapterStrip.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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('L12–L15')).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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -189,6 +189,9 @@ describe('CommandCenterPage', () => {
|
|||||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
||||||
|
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Circuit context')
|
||||||
|
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Weekend preview')
|
||||||
|
expect(screen.getByTestId('cc-data-note')).toHaveTextContent('availability is shown per session')
|
||||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
||||||
})
|
})
|
||||||
@@ -238,8 +241,40 @@ describe('CommandCenterPage', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
||||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
||||||
|
expect(screen.getByTestId('cc-calendar-1301')).toHaveTextContent('Archive')
|
||||||
|
expect(screen.getByTestId('cc-calendar-1302')).toHaveTextContent('Now')
|
||||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||||
|
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the completed local session rail available when the next weekend has no sessions', async () => {
|
||||||
|
const nextMeeting = {
|
||||||
|
...meeting,
|
||||||
|
meeting_key: 1303,
|
||||||
|
meeting_name: 'Canada',
|
||||||
|
country_name: 'Canada',
|
||||||
|
country_code: 'CAN',
|
||||||
|
circuit_short_name: 'Montreal',
|
||||||
|
date_start: '2026-06-12T00:00:00+00:00',
|
||||||
|
date_end: '2026-06-14T23:59:59+00:00',
|
||||||
|
year: 2026,
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
|
||||||
|
mockFetchSeasons.mockResolvedValue([2026])
|
||||||
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
|
mockFetchSeasonMeetings.mockResolvedValue([meeting, nextMeeting])
|
||||||
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
|
mockFetchSessions.mockResolvedValue([])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('cc-schedule')).toHaveTextContent('1 sessions')
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -209,6 +209,32 @@ describe('TimingTower', () => {
|
|||||||
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
|
||||||
|
const practiceRows = [
|
||||||
|
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
|
||||||
|
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
|
||||||
|
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
|
||||||
|
]
|
||||||
|
render(
|
||||||
|
<TimingTower
|
||||||
|
rows={practiceRows}
|
||||||
|
session={{
|
||||||
|
MeetingName: 'Belgian Grand Prix',
|
||||||
|
CircuitName: 'Spa',
|
||||||
|
SessionType: 'Practice',
|
||||||
|
SessionName: 'Practice 2',
|
||||||
|
Path: '',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Leader shows a clear leader marker, not a fabricated gap.
|
||||||
|
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
|
||||||
|
expect(screen.getByText('+0.190')).toBeInTheDocument()
|
||||||
|
// A driver without a valid best lap gets no invented gap.
|
||||||
|
const lecRow = screen.getByText('LEC').closest('tr')!
|
||||||
|
expect(lecRow).not.toHaveTextContent('+')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
|
|||||||
@@ -153,6 +153,29 @@ describe('LiveTimingPage archive mode', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
|
||||||
|
renderPage({
|
||||||
|
is_live: true,
|
||||||
|
data: {
|
||||||
|
...archivedSnapshot,
|
||||||
|
SessionStatus: 'Inactive',
|
||||||
|
TrackStatus: '2',
|
||||||
|
Clock: '00:03:27',
|
||||||
|
Session: {
|
||||||
|
MeetingName: 'Belgian Grand Prix',
|
||||||
|
CircuitName: 'Spa-Francorchamps',
|
||||||
|
SessionType: 'Practice',
|
||||||
|
SessionName: 'Practice 2',
|
||||||
|
Path: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||||
renderPage({
|
renderPage({
|
||||||
is_live: true,
|
is_live: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -9,21 +9,24 @@ import {
|
|||||||
createRoute,
|
createRoute,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import { RaceHubPage } from '../pages/RaceHubPage'
|
import { RaceHubPage } from '../pages/RaceHubPage'
|
||||||
import type { DatasetInfo, Meeting, RaceHub, Session, Weekend } from '../types'
|
import { MAX_BROWSER_TIMEOUT } from '../lib/schedule'
|
||||||
|
import type { ContextAvailability, DatasetInfo, Meeting, RaceHub, Session, Weekend, WeekendContext } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchRaceHub: vi.fn(),
|
fetchRaceHub: vi.fn(),
|
||||||
fetchSeasons: vi.fn(),
|
fetchSeasons: vi.fn(),
|
||||||
fetchLocalMeetings: vi.fn(),
|
fetchLocalMeetings: vi.fn(),
|
||||||
fetchWeekend: vi.fn(),
|
fetchWeekend: vi.fn(),
|
||||||
|
fetchWeekendContext: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { fetchRaceHub, fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
|
import { fetchLocalMeetings, fetchRaceHub, fetchSeasons, fetchWeekend, fetchWeekendContext } from '../api'
|
||||||
|
|
||||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||||
|
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||||
|
|
||||||
const meeting: Meeting = {
|
const meeting: Meeting = {
|
||||||
meeting_key: 1229,
|
meeting_key: 1229,
|
||||||
@@ -169,6 +172,26 @@ const weekend: Weekend = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availability: ContextAvailability = {
|
||||||
|
source: 'local',
|
||||||
|
schedule: 'available',
|
||||||
|
live_transport: 'unknown',
|
||||||
|
live_session: 'inactive',
|
||||||
|
archive: 'unavailable',
|
||||||
|
local_analysis: 'complete',
|
||||||
|
freshness: 'local',
|
||||||
|
limitations: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysisContext: WeekendContext = {
|
||||||
|
temporal_state: 'between_weekends',
|
||||||
|
default_analysis_session: { session: raceSession, meeting, availability },
|
||||||
|
race_hub_default_session: { session: raceSession, meeting, availability },
|
||||||
|
race_hub_pre_session: false,
|
||||||
|
championship_round: 1,
|
||||||
|
total_championship_rounds: 24,
|
||||||
|
}
|
||||||
|
|
||||||
function renderRaceHub(sessionKey: number) {
|
function renderRaceHub(sessionKey: number) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
@@ -193,23 +216,28 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
return <RaceHubPage sessionKey={session_key ?? 0} />
|
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
window.history.pushState({}, '', sessionKey ? `/race-hub?session_key=${sessionKey}` : '/race-hub')
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree: rootRoute.addChildren([raceHubRoute]),
|
routeTree: rootRoute.addChildren([raceHubRoute]),
|
||||||
history: undefined,
|
history: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigate to the URL before mounting
|
return { queryClient, ...render(<RouterProvider router={router} />) }
|
||||||
router.navigate({ to: '/race-hub', search: sessionKey ? { session_key: sessionKey } : {} })
|
|
||||||
return render(<RouterProvider router={router} />)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('RaceHubPage', () => {
|
describe('RaceHubPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
mockFetchWeekend.mockResolvedValue(weekend)
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
mockFetchWeekendContext.mockResolvedValue(analysisContext)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
||||||
@@ -256,4 +284,122 @@ describe('RaceHubPage', () => {
|
|||||||
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||||
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the server-selected completed analysis session for bare Race Hub without changing the URL', async () => {
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9472))
|
||||||
|
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the intentional pre-session state without analysis cards', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
|
||||||
|
meeting,
|
||||||
|
availability,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: '2099-05-23T13:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-pre-session-schedule')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the weekend switcher from pre-session and navigates to the selected explicit session', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: '2099-05-23T13:00:00Z' },
|
||||||
|
meeting,
|
||||||
|
availability,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: '2099-05-23T13:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
const switchWeekend = await screen.findByTestId('rh-switch-weekend')
|
||||||
|
expect(switchWeekend).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
fireEvent.click(switchWeekend)
|
||||||
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
|
expect(switchWeekend).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByTestId('rh-switcher-session-9471'))
|
||||||
|
await waitFor(() => expect(window.location.search).toBe('?session_key=9471'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows recovery instead of selecting an empty future session', async () => {
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: undefined,
|
||||||
|
race_hub_pre_session: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('race-hub-empty')).toHaveTextContent('No completed local analysis yet')
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands a bare route from completed analysis to pre-session at the supplied refresh boundary', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
const handoff = new Date(Date.now() + 10_000).toISOString()
|
||||||
|
const pendingContext: WeekendContext = {
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_default_session: {
|
||||||
|
session: { ...raceSession, session_key: 9473, session_name: 'Practice 1', session_type: 'Practice', date_start: handoff },
|
||||||
|
meeting,
|
||||||
|
availability,
|
||||||
|
},
|
||||||
|
race_hub_pre_session: true,
|
||||||
|
race_hub_refresh_at: new Date(Date.now() + 16_000).toISOString(),
|
||||||
|
}
|
||||||
|
mockFetchWeekendContext
|
||||||
|
.mockResolvedValueOnce({ ...analysisContext, race_hub_refresh_at: handoff })
|
||||||
|
.mockResolvedValueOnce(pendingContext)
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
await screen.findByTestId('race-hub')
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('race-hub-pre-session')).toBeInTheDocument()
|
||||||
|
expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-arms a bare route refresh after a capped browser timer', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
mockFetchWeekendContext.mockResolvedValue({
|
||||||
|
...analysisContext,
|
||||||
|
race_hub_refresh_at: new Date(Date.now() + MAX_BROWSER_TIMEOUT + 1_000).toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
await screen.findByTestId('race-hub')
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(MAX_BROWSER_TIMEOUT) })
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(2))
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||||
|
await waitFor(() => expect(mockFetchWeekendContext).toHaveBeenCalledTimes(3))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an explicit session URL stable across the canonical refresh boundary', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
|
||||||
|
|
||||||
|
expect(mockFetchWeekendContext).not.toHaveBeenCalled()
|
||||||
|
expect(mockFetchRaceHub).toHaveBeenCalledWith(9472)
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9473)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
277
frontend/src/test/RacePreviewPage.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
291
frontend/src/test/RaceStoryCanvas.test.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
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 { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
|
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchReplayFrames: vi.fn(),
|
||||||
|
fetchTrackOutline: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||||
|
|
||||||
|
const mockFetchReplayFrames = vi.mocked(fetchReplayFrames)
|
||||||
|
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 1,
|
||||||
|
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay: ReplayFramesResponse = {
|
||||||
|
session_key: 99,
|
||||||
|
interval_ms: 5000,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
frames: [
|
||||||
|
{ t: 0, cars: { '1': { x: 10, y: 20 } } },
|
||||||
|
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters: Chapter[] = [
|
||||||
|
{
|
||||||
|
kind: 'start',
|
||||||
|
title: 'Start',
|
||||||
|
headline: 'Lights out',
|
||||||
|
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',
|
||||||
|
headline: 'Incident brings out the Safety Car',
|
||||||
|
start_lap: 2,
|
||||||
|
end_lap: 2,
|
||||||
|
start_time: '2025-05-25T13:05:00Z',
|
||||||
|
end_time: '2025-05-25T13:05:00Z',
|
||||||
|
driver_numbers: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const raceHub: RaceHub = {
|
||||||
|
source: 'local',
|
||||||
|
session_key: 99,
|
||||||
|
datasets: {
|
||||||
|
positions: { status: 'available', source: 'local', count: 2 },
|
||||||
|
},
|
||||||
|
meeting: {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Monaco Grand Prix',
|
||||||
|
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 1,
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00Z',
|
||||||
|
date_end: '2025-05-25T00:00:00Z',
|
||||||
|
year: 2025,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
session_key: 99,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
circuit_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2025-05-25T13:00:00Z',
|
||||||
|
date_end: '2025-05-25T15:00:00Z',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
drivers: [],
|
||||||
|
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: null,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 99,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
starting_grid: [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
session_key: 99,
|
||||||
|
meeting_key: 1,
|
||||||
|
lap_duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stints: [],
|
||||||
|
pit_stops: [],
|
||||||
|
positions: [
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:00:00Z', position: 1 },
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
|
||||||
|
],
|
||||||
|
race_control: [],
|
||||||
|
weather: [],
|
||||||
|
laps: [],
|
||||||
|
chapters,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCanvas(overrides: Partial<RaceHub> = {}) {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RaceStoryCanvas replay map', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchReplayFrames.mockResolvedValue(replay)
|
||||||
|
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('probes replay frames on mount and opens the map when data is available', async () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
||||||
|
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
||||||
|
|
||||||
|
const mapToggle = await screen.findByTestId('replay-map-toggle')
|
||||||
|
fireEvent.click(mapToggle)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the map toggle when replay frames are unavailable', async () => {
|
||||||
|
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses full-width chart layout when the map is closed', async () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
||||||
|
const chart = screen.getByTestId('position-chart')
|
||||||
|
expect(chart).toHaveClass('rs-chart-container--full')
|
||||||
|
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
|
||||||
|
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses lap timing rather than pre-race position samples for the chart scale', () => {
|
||||||
|
const laps = Array.from({ length: 20 }, (_, index) => ({
|
||||||
|
session_key: 99,
|
||||||
|
driver_number: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
lap_number: index + 1,
|
||||||
|
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
|
||||||
|
lap_duration: 60,
|
||||||
|
is_pit_out_lap: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
renderCanvas({
|
||||||
|
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
|
||||||
|
laps,
|
||||||
|
positions: [
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
|
||||||
|
const lapTenLabel = screen.getByText('L10')
|
||||||
|
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
|
||||||
|
// The pre-race position is retained as the starting grid at the left edge.
|
||||||
|
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
const svg = document.querySelector('.rs-position-chart-svg')!
|
||||||
|
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 360,
|
||||||
|
top: 0,
|
||||||
|
right: 1280,
|
||||||
|
bottom: 360,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pointerMove = new Event('pointermove', { bubbles: true })
|
||||||
|
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
|
||||||
|
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
|
||||||
|
|
||||||
|
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
|
||||||
|
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs active chapter highlight when a chapter card is clicked', async () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('chapter-card-1'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
|
||||||
|
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights out-of-window chapters after click (clamped scrub + selection)', async () => {
|
||||||
|
// Position samples start at 13:05; start chapter ends at 13:01 — outside the window.
|
||||||
|
renderCanvas({
|
||||||
|
positions: [
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 },
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
kind: 'start',
|
||||||
|
title: 'Start',
|
||||||
|
headline: 'Lights out before samples',
|
||||||
|
start_lap: 1,
|
||||||
|
end_lap: 1,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
end_time: '2025-05-25T13:01:00Z',
|
||||||
|
driver_numbers: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'finish',
|
||||||
|
title: 'Finish',
|
||||||
|
headline: 'Flag after samples',
|
||||||
|
start_lap: 78,
|
||||||
|
end_lap: 78,
|
||||||
|
start_time: '2025-05-25T13:20:00Z',
|
||||||
|
end_time: '2025-05-25T13:21:00Z',
|
||||||
|
driver_numbers: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('chapter-card-0'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active'))
|
||||||
|
expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('chapter-card-1'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
|
||||||
|
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the empty-state card when positions are unavailable', () => {
|
||||||
|
renderCanvas({
|
||||||
|
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||||
|
positions: [],
|
||||||
|
chapters: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
57
frontend/src/test/ReplayTrackMap.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ReplayTrackMap } from '../components/ReplayTrackMap'
|
||||||
|
import type { EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 1,
|
||||||
|
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay: ReplayFramesResponse = {
|
||||||
|
session_key: 99,
|
||||||
|
interval_ms: 5000,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
frames: [
|
||||||
|
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
|
||||||
|
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
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: null,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 99,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ReplayTrackMap', () => {
|
||||||
|
it('renders an empty-state card when replay frames are missing', () => {
|
||||||
|
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||||
|
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders car labels from result metadata', () => {
|
||||||
|
render(<ReplayTrackMap outline={outline} replay={replay} tMs={2500} drivers={[]} results={results} />)
|
||||||
|
expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
|
|
||||||
|
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||||
|
return {
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 100,
|
||||||
|
position: 1,
|
||||||
|
wins: 2,
|
||||||
|
podiums: 3,
|
||||||
|
poles: 1,
|
||||||
|
form: [25, 18, 25],
|
||||||
|
cumulative: [25, 43, 68],
|
||||||
|
round_positions: [1, 2, 1],
|
||||||
|
teammate_wins: 3,
|
||||||
|
teammate_losses: 0,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
|
||||||
|
return {
|
||||||
|
season: 2025,
|
||||||
|
round: 3,
|
||||||
|
total_rounds: 10,
|
||||||
|
rounds_left: 7,
|
||||||
|
last_race: 'Japan GP',
|
||||||
|
round_labels: ['R1', 'R2', 'R3'],
|
||||||
|
drivers,
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hub = makeHub([
|
||||||
|
driver({}),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 90,
|
||||||
|
position: 2,
|
||||||
|
cumulative: [18, 43, 61],
|
||||||
|
round_positions: [2, 1, 2],
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 16,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'e8002d',
|
||||||
|
points: 50,
|
||||||
|
position: 3,
|
||||||
|
cumulative: [15, 28, 40],
|
||||||
|
round_positions: [3, 0, 3],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
describe('RivalryCompare', () => {
|
||||||
|
it('defaults to the top two drivers and shows the H2H score', () => {
|
||||||
|
render(<RivalryCompare hub={hub} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||||
|
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||||
|
// VER wins R1 and R3, NOR wins R2.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–1')
|
||||||
|
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||||
|
// Last gap: 68 − 61 = +7 → VER ahead.
|
||||||
|
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recomputes when a different driver is picked and skips missing rounds', () => {
|
||||||
|
render(<RivalryCompare hub={hub} />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
|
||||||
|
|
||||||
|
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–0')
|
||||||
|
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty state with fewer than two drivers', () => {
|
||||||
|
render(<RivalryCompare hub={makeHub([driver({})])} />)
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty message when no rounds are completed', () => {
|
||||||
|
const empty = makeHub([
|
||||||
|
driver({ cumulative: [], round_positions: [], form: [] }),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
|
form: [],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
empty.round = 0
|
||||||
|
empty.round_labels = []
|
||||||
|
|
||||||
|
render(<RivalryCompare hub={empty} />)
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
|
||||||
|
})
|
||||||
|
})
|
||||||
61
frontend/src/test/SessionBanner.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { SessionBanner } from '../components/live/SessionBanner'
|
||||||
|
import type { LiveStreamData } from '../types'
|
||||||
|
|
||||||
|
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
|
||||||
|
return {
|
||||||
|
Drivers: {},
|
||||||
|
DriverInfo: {},
|
||||||
|
Tyres: {},
|
||||||
|
RCMessages: [],
|
||||||
|
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
|
||||||
|
Session: {
|
||||||
|
MeetingName: 'Belgian Grand Prix',
|
||||||
|
CircuitName: 'Spa-Francorchamps',
|
||||||
|
SessionType: sessionType,
|
||||||
|
SessionName: sessionName,
|
||||||
|
Path: '',
|
||||||
|
},
|
||||||
|
TeamRadio: [],
|
||||||
|
TrackStatus: '1',
|
||||||
|
CurrentLap: 0,
|
||||||
|
TotalLaps: 0,
|
||||||
|
Clock: '00:45:00',
|
||||||
|
ClockRefTime: '',
|
||||||
|
ClockExtrapolating: false,
|
||||||
|
Stints: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionBanner', () => {
|
||||||
|
it('never renders a race lap counter for a practice session', () => {
|
||||||
|
render(
|
||||||
|
<SessionBanner
|
||||||
|
isLive
|
||||||
|
snapshot={makeSnapshot('Practice', 'Practice 2')}
|
||||||
|
rows={[]}
|
||||||
|
connection="connected"
|
||||||
|
now={0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
|
||||||
|
// Session identity and clock stay intact.
|
||||||
|
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the lap counter for a race session', () => {
|
||||||
|
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
|
||||||
|
render(
|
||||||
|
<SessionBanner
|
||||||
|
isLive
|
||||||
|
snapshot={snapshot}
|
||||||
|
rows={[]}
|
||||||
|
connection="connected"
|
||||||
|
now={0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
|
||||||
|
})
|
||||||
|
})
|
||||||
54
frontend/src/test/TeammateH2H.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { TeammateH2H } from '../components/TeammateH2H'
|
||||||
|
|
||||||
|
describe('TeammateH2H', () => {
|
||||||
|
it('renders score and TLAs', () => {
|
||||||
|
render(
|
||||||
|
<TeammateH2H
|
||||||
|
teamName="Red Bull"
|
||||||
|
teamColour="3671c6"
|
||||||
|
driverATla="VER"
|
||||||
|
driverBTla="PER"
|
||||||
|
winsA={9}
|
||||||
|
winsB={1}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('PER')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('h2h-score')).toHaveTextContent('9–1')
|
||||||
|
expect(screen.getByText('Red Bull')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets proportional bar segment widths from wins', () => {
|
||||||
|
render(
|
||||||
|
<TeammateH2H
|
||||||
|
teamName="McLaren"
|
||||||
|
teamColour="ff8000"
|
||||||
|
driverATla="NOR"
|
||||||
|
driverBTla="PIA"
|
||||||
|
winsA={6}
|
||||||
|
winsB={4}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const barA = screen.getByTestId('h2h-bar-a')
|
||||||
|
const barB = screen.getByTestId('h2h-bar-b')
|
||||||
|
expect(barA).toHaveStyle({ width: '60%' })
|
||||||
|
expect(barB).toHaveStyle({ width: '40%' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows extra driver note when provided', () => {
|
||||||
|
render(
|
||||||
|
<TeammateH2H
|
||||||
|
teamName="Red Bull"
|
||||||
|
teamColour="3671c6"
|
||||||
|
driverATla="VER"
|
||||||
|
driverBTla="PER"
|
||||||
|
winsA={5}
|
||||||
|
winsB={3}
|
||||||
|
extraNote="+1"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TyreDegPanel', () => {
|
describe('TyreDegPanel', () => {
|
||||||
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
it('stays collapsed while every stint is still warming up', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
|
||||||
|
// A panel of "warming up" placeholders carries no information and used to
|
||||||
|
// push the Timing Tower off the fold for the first third of a race.
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
expect(panel).toHaveTextContent('collecting clean laps')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a warming-up placeholder on each row once expanded', () => {
|
||||||
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
expect(panel).toHaveTextContent('VER')
|
expect(panel).toHaveTextContent('VER')
|
||||||
expect(panel).toHaveTextContent('M +5')
|
expect(panel).toHaveTextContent('M +5')
|
||||||
expect(panel).toHaveTextContent('fresh')
|
expect(panel).toHaveTextContent('fresh')
|
||||||
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||||
]
|
]
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -81,13 +93,46 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).toHaveTextContent('→ ~P2')
|
expect(panel).toHaveTextContent('→ ~P2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
|
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
|
||||||
|
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
expect(panel).not.toHaveTextContent('~P')
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens itself during a race as soon as a stint has signal', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
})
|
||||||
|
|
||||||
|
it('keeps the reader\'s own collapse choice when signal arrives', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
// Reader opens it early, then closes it again — that decision must stick
|
||||||
|
// even once the panel would otherwise auto-open.
|
||||||
|
const toggle = screen.getByRole('button', { name: /tyre deg/i })
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,6 +141,7 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
)
|
)
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||||
|
|||||||
124
frontend/src/test/chapters.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
activeChapterIndex,
|
||||||
|
chapterBandFill,
|
||||||
|
chapterKindLabel,
|
||||||
|
chapterLapRange,
|
||||||
|
chapterStartScrub,
|
||||||
|
chapterTourDurations,
|
||||||
|
deCollideYPositions,
|
||||||
|
decimatedPositionLabels,
|
||||||
|
} from '../lib/chapters'
|
||||||
|
import type { Chapter } from '../types'
|
||||||
|
|
||||||
|
const sampleChapters: 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],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('chapters lib', () => {
|
||||||
|
it('labels chapter kinds', () => {
|
||||||
|
expect(chapterKindLabel('safety_car')).toBe('SC')
|
||||||
|
expect(chapterKindLabel('finish')).toBe('FIN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats lap ranges', () => {
|
||||||
|
expect(chapterLapRange(sampleChapters[0])).toBe('L1')
|
||||||
|
expect(chapterLapRange(sampleChapters[1])).toBe('L12–L15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps chapter start time to scrub position', () => {
|
||||||
|
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
|
||||||
|
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
|
||||||
|
const scrub = chapterStartScrub(sampleChapters[1], tMin, tMax - tMin)
|
||||||
|
expect(scrub).toBeCloseTo(0.6, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds active chapter from scrub time', () => {
|
||||||
|
const tMin = new Date('2025-05-25T13:00:00Z').getTime()
|
||||||
|
const tMax = new Date('2025-05-25T13:20:00Z').getTime()
|
||||||
|
const tRange = tMax - tMin
|
||||||
|
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
||||||
|
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates chapters whose timestamps clamp outside the position window', () => {
|
||||||
|
// Position samples only cover 13:05–13:10; chapters sit before/after that window.
|
||||||
|
const tMin = new Date('2025-05-25T13:05:00Z').getTime()
|
||||||
|
const tMax = new Date('2025-05-25T13:10:00Z').getTime()
|
||||||
|
const tRange = tMax - tMin
|
||||||
|
const outOfWindow: Chapter[] = [
|
||||||
|
{
|
||||||
|
kind: 'start',
|
||||||
|
title: 'Start',
|
||||||
|
headline: 'Lights out',
|
||||||
|
start_lap: 1,
|
||||||
|
end_lap: 1,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
end_time: '2025-05-25T13:01:00Z',
|
||||||
|
driver_numbers: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'finish',
|
||||||
|
title: 'Finish',
|
||||||
|
headline: 'Chequered flag',
|
||||||
|
start_lap: 78,
|
||||||
|
end_lap: 78,
|
||||||
|
start_time: '2025-05-25T13:20:00Z',
|
||||||
|
end_time: '2025-05-25T13:21:00Z',
|
||||||
|
driver_numbers: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0)
|
||||||
|
expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1)
|
||||||
|
expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0)
|
||||||
|
expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1)
|
||||||
|
expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits 90s evenly across chapters', () => {
|
||||||
|
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decimates position axis labels', () => {
|
||||||
|
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns chapter band fills by kind', () => {
|
||||||
|
expect(chapterBandFill('safety_car')).toContain('rgba')
|
||||||
|
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('de-collides overlapping label y positions', () => {
|
||||||
|
const adjusted = deCollideYPositions(
|
||||||
|
[
|
||||||
|
{ key: 'a', y: 10 },
|
||||||
|
{ key: 'b', y: 12 },
|
||||||
|
{ key: 'c', y: 30 },
|
||||||
|
],
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
expect(adjusted.get('a')).toBe(10)
|
||||||
|
expect(adjusted.get('b')).toBe(22)
|
||||||
|
expect(adjusted.get('c')).toBe(34)
|
||||||
|
})
|
||||||
|
})
|
||||||
266
frontend/src/test/digest.test.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
filterByTag,
|
||||||
|
groupByWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
sortWindowBucketsNewestFirst,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
windowForDate,
|
||||||
|
} from '../lib/digest'
|
||||||
|
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||||
|
|
||||||
|
const meeting = (overrides: Partial<Meeting> = {}): 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,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const news = (overrides: Partial<NewsItem> = {}): NewsItem => ({
|
||||||
|
source: 'autosport-f1',
|
||||||
|
title: 'Headline',
|
||||||
|
url: `https://example.com/${Math.random()}`,
|
||||||
|
fetched_at: '2025-04-01T12:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const driver = (over: Partial<ChampHubDriver>): 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: [],
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const team = (over: Partial<ChampHubTeam>): ChampHubTeam => ({
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 150,
|
||||||
|
position: 1,
|
||||||
|
wins: 4,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gpWindows', () => {
|
||||||
|
const bahrain = meeting({ meeting_key: 1, meeting_name: 'Bahrain' })
|
||||||
|
const monaco = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
const canada = meeting({
|
||||||
|
meeting_key: 3,
|
||||||
|
meeting_name: 'Canada',
|
||||||
|
date_start: '2025-06-13T00:00:00+00:00',
|
||||||
|
date_end: '2025-06-15T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives inter-race windows mid-season', () => {
|
||||||
|
const now = new Date('2025-04-10T12:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco, canada], now)
|
||||||
|
|
||||||
|
expect(windows).toHaveLength(3)
|
||||||
|
expect(windows[0].start).toBeNull()
|
||||||
|
expect(windows[0].end?.toISOString()).toBe(new Date('2025-05-23T00:00:00+00:00').toISOString())
|
||||||
|
expect(windows[1].start?.toISOString()).toBe(new Date('2025-03-16T23:59:59+00:00').toISOString())
|
||||||
|
expect(windows[1].end?.toISOString()).toBe(new Date('2025-06-13T00:00:00+00:00').toISOString())
|
||||||
|
expect(windows[2].start?.toISOString()).toBe(new Date('2025-05-25T23:59:59+00:00').toISOString())
|
||||||
|
expect(windows[2].end).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles before the first race of the season', () => {
|
||||||
|
const now = new Date('2025-01-01T00:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco], now)
|
||||||
|
expect(windows[0].start).toBeNull()
|
||||||
|
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Bahrain')
|
||||||
|
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Before Bahrain')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles after the final race of the season', () => {
|
||||||
|
const now = new Date('2025-12-01T00:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco], now)
|
||||||
|
expect(activeDigestWindow(windows, [bahrain, monaco], now)?.meeting_name).toBe('Monaco')
|
||||||
|
expect(sinceLastLabel([bahrain, monaco], now)).toBe('Monaco')
|
||||||
|
expect(windows[1].end).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groupByWindow', () => {
|
||||||
|
const bahrain = meeting({ meeting_key: 1 })
|
||||||
|
const monaco = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
const windows = gpWindows([bahrain, monaco], new Date('2025-04-10T12:00:00Z'))
|
||||||
|
|
||||||
|
it('buckets dated items into the matching GP window', () => {
|
||||||
|
const items = [
|
||||||
|
news({ url: 'https://a', published_at: '2025-03-10T10:00:00Z', title: 'Pre-Bahrain' }),
|
||||||
|
news({ url: 'https://b', published_at: '2025-04-05T10:00:00Z', title: 'Between races' }),
|
||||||
|
news({ url: 'https://c', published_at: '2025-05-24T10:00:00Z', title: 'Monaco weekend' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const grouped = groupByWindow(items, windows)
|
||||||
|
const bahrainItems = grouped.windows.find((entry) => entry.window.meeting_key === 1)?.items ?? []
|
||||||
|
const monacoItems = grouped.windows.find((entry) => entry.window.meeting_key === 2)?.items ?? []
|
||||||
|
|
||||||
|
expect(bahrainItems).toHaveLength(1)
|
||||||
|
expect(bahrainItems[0].title).toBe('Pre-Bahrain')
|
||||||
|
expect(monacoItems).toHaveLength(2)
|
||||||
|
expect(monacoItems.map((item) => item.title)).toEqual(['Between races', 'Monaco weekend'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts undated items in the recent bucket', () => {
|
||||||
|
const items = [
|
||||||
|
news({ url: 'https://u', title: 'Undated story' }),
|
||||||
|
news({ url: 'https://d', published_at: '2025-04-05T10:00:00Z' }),
|
||||||
|
]
|
||||||
|
const grouped = groupByWindow(items, windows)
|
||||||
|
expect(grouped.recent).toHaveLength(1)
|
||||||
|
expect(grouped.recent[0].url).toBe('https://u')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts window buckets newest first for display', () => {
|
||||||
|
const items = [
|
||||||
|
news({ published_at: '2025-03-10T10:00:00Z' }),
|
||||||
|
news({ published_at: '2025-05-24T10:00:00Z' }),
|
||||||
|
]
|
||||||
|
const grouped = groupByWindow(items, windows)
|
||||||
|
const sorted = sortWindowBucketsNewestFirst(grouped.windows)
|
||||||
|
expect(sorted[0].window.meeting_key).toBe(2)
|
||||||
|
expect(sorted[1].window.meeting_key).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tagItems', () => {
|
||||||
|
const drivers = [
|
||||||
|
driver({ name_acronym: 'VER', full_name: 'Max Verstappen', team_name: 'Red Bull' }),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const teams = [
|
||||||
|
team({ team_name: 'Red Bull' }),
|
||||||
|
team({ team_name: 'McLaren', team_colour: 'ff8000' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
it('tags drivers and teams case-insensitively', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'ver leads mclaren in practice', summary: 'Norris close behind' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
const labels = item.tags.map((tag) => tag.label).sort()
|
||||||
|
expect(labels).toEqual(['McLaren', 'NOR', 'VER'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no tags when nothing matches', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'Generic paddock update' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
expect(item.tags).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the longest overlapping match', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'Max Verstappen extends championship lead' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
expect(item.tags).toHaveLength(1)
|
||||||
|
expect(item.tags[0].label).toBe('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches a driver last name inside prose', () => {
|
||||||
|
const [item] = tagItems(
|
||||||
|
[news({ title: 'Verstappen was untouchable in qualifying' })],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
)
|
||||||
|
expect(item.tags.some((tag) => tag.label === 'VER')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('digest helpers', () => {
|
||||||
|
it('collects top tags by frequency', () => {
|
||||||
|
const tagged = tagItems(
|
||||||
|
[
|
||||||
|
news({ title: 'VER wins', url: 'https://a' }),
|
||||||
|
news({ title: 'VER dominates', url: 'https://b' }),
|
||||||
|
news({ title: 'Norris podium', url: 'https://c' }),
|
||||||
|
],
|
||||||
|
[driver({}), driver({ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando Norris', team_name: 'McLaren' })],
|
||||||
|
[team({}), team({ team_name: 'McLaren', team_colour: 'ff8000' })],
|
||||||
|
)
|
||||||
|
expect(topTags(tagged, 2).map((tag) => tag.label)).toEqual(['VER', 'NOR'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters items by tag key', () => {
|
||||||
|
const tagged = tagItems(
|
||||||
|
[
|
||||||
|
news({ title: 'VER wins', url: 'https://a' }),
|
||||||
|
news({ title: 'Neutral headline', url: 'https://b' }),
|
||||||
|
],
|
||||||
|
[driver({})],
|
||||||
|
[team({})],
|
||||||
|
)
|
||||||
|
const filtered = filterByTag(tagged, 'driver:VER')
|
||||||
|
expect(filtered).toHaveLength(1)
|
||||||
|
expect(filtered[0].url).toBe('https://a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects items for the active digest window', () => {
|
||||||
|
const bahrain = meeting({ meeting_key: 1 })
|
||||||
|
const monaco = meeting({
|
||||||
|
meeting_key: 2,
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
})
|
||||||
|
const now = new Date('2025-04-10T12:00:00Z')
|
||||||
|
const windows = gpWindows([bahrain, monaco], now)
|
||||||
|
const active = activeDigestWindow(windows, [bahrain, monaco], now)
|
||||||
|
const tagged = tagItems(
|
||||||
|
[
|
||||||
|
news({ published_at: '2025-04-05T10:00:00Z', url: 'https://in' }),
|
||||||
|
news({ published_at: '2025-03-10T10:00:00Z', url: 'https://out' }),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const inWindow = itemsForWindow(tagged, active)
|
||||||
|
expect(inWindow).toHaveLength(1)
|
||||||
|
expect(inWindow[0].url).toBe('https://in')
|
||||||
|
expect(windowForDate(windows, new Date('2025-04-05T10:00:00Z'))?.meeting_key).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
104
frontend/src/test/driverProfile.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatDelta,
|
||||||
|
formatPosition,
|
||||||
|
gridFinishDeltas,
|
||||||
|
shortGpLabel,
|
||||||
|
} from '../lib/driverProfile'
|
||||||
|
import type { DriverSummaryRound } from '../types'
|
||||||
|
|
||||||
|
function round(over: Partial<DriverSummaryRound>): DriverSummaryRound {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gridFinishDeltas', () => {
|
||||||
|
it('computes positive delta for places gained', () => {
|
||||||
|
const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })])
|
||||||
|
expect(d.grid).toBe(5)
|
||||||
|
expect(d.finish).toBe(2)
|
||||||
|
expect(d.delta).toBe(3)
|
||||||
|
expect(d.points).toBe(18)
|
||||||
|
expect(d.status).toBe('classified')
|
||||||
|
expect(d.round).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes negative delta for places lost', () => {
|
||||||
|
const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })])
|
||||||
|
expect(d.delta).toBe(-3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null delta when the grid slot is unknown (position 0)', () => {
|
||||||
|
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })])
|
||||||
|
expect(d.grid).toBeNull()
|
||||||
|
expect(d.finish).toBe(6)
|
||||||
|
expect(d.delta).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags DNS rounds and never computes a delta for them', () => {
|
||||||
|
const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })])
|
||||||
|
expect(d.status).toBe('dns')
|
||||||
|
expect(d.delta).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks rounds the driver did not enter as absent', () => {
|
||||||
|
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })])
|
||||||
|
expect(d.status).toBe('absent')
|
||||||
|
expect(d.grid).toBeNull()
|
||||||
|
expect(d.finish).toBeNull()
|
||||||
|
expect(d.delta).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still computes the delta for a classified DNF with known positions', () => {
|
||||||
|
const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })])
|
||||||
|
expect(d.status).toBe('dnf')
|
||||||
|
expect(d.delta).toBe(-12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('numbers rounds sequentially and shortens labels', () => {
|
||||||
|
const ds = gridFinishDeltas([
|
||||||
|
round({}),
|
||||||
|
round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }),
|
||||||
|
])
|
||||||
|
expect(ds.map((d) => d.round)).toEqual([1, 2])
|
||||||
|
expect(ds[1].label).toBe('Saudi Arabian')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shortGpLabel', () => {
|
||||||
|
it('strips "Grand Prix" wherever it appears', () => {
|
||||||
|
expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain')
|
||||||
|
expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the original name when stripping empties it', () => {
|
||||||
|
expect(shortGpLabel('Grand Prix')).toBe('Grand Prix')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatPosition / formatDelta', () => {
|
||||||
|
it('formats positions', () => {
|
||||||
|
expect(formatPosition(4)).toBe('P4')
|
||||||
|
expect(formatPosition(null)).toBe('—')
|
||||||
|
expect(formatPosition(0)).toBe('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats deltas', () => {
|
||||||
|
expect(formatDelta(3)).toBe('+3')
|
||||||
|
expect(formatDelta(-2)).toBe('−2')
|
||||||
|
expect(formatDelta(0)).toBe('=')
|
||||||
|
expect(formatDelta(null)).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
133
frontend/src/test/h2h.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { teammatePairs } from '../lib/h2h'
|
||||||
|
import type { ChampHubDriver } from '../types'
|
||||||
|
|
||||||
|
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||||
|
return {
|
||||||
|
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: [],
|
||||||
|
cumulative: [],
|
||||||
|
teammate_wins: 9,
|
||||||
|
teammate_losses: 1,
|
||||||
|
round_positions: [],
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teammatePairs', () => {
|
||||||
|
it('returns empty for no drivers', () => {
|
||||||
|
expect(teammatePairs([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips teams with a single driver', () => {
|
||||||
|
const drivers = [
|
||||||
|
driver({ driver_number: 1, team_name: 'Red Bull' }),
|
||||||
|
driver({ driver_number: 44, name_acronym: 'HAM', team_name: 'Mercedes', points: 50 }),
|
||||||
|
]
|
||||||
|
expect(teammatePairs(drivers)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pairs two-driver teams', () => {
|
||||||
|
const drivers = [
|
||||||
|
driver({ driver_number: 1, name_acronym: 'VER', points: 200, teammate_wins: 9, teammate_losses: 1 }),
|
||||||
|
driver({
|
||||||
|
driver_number: 11,
|
||||||
|
name_acronym: 'PER',
|
||||||
|
full_name: 'Sergio Perez',
|
||||||
|
points: 60,
|
||||||
|
teammate_wins: 1,
|
||||||
|
teammate_losses: 9,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const pairs = teammatePairs(drivers)
|
||||||
|
expect(pairs).toHaveLength(1)
|
||||||
|
expect(pairs[0].teamName).toBe('Red Bull')
|
||||||
|
expect(pairs[0].driverA.name_acronym).toBe('VER')
|
||||||
|
expect(pairs[0].driverB.name_acronym).toBe('PER')
|
||||||
|
expect(pairs[0].extraCount).toBe(0)
|
||||||
|
expect(pairs[0].closeness).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses top two by points for teams with 3+ drivers', () => {
|
||||||
|
const drivers = [
|
||||||
|
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200 }),
|
||||||
|
driver({
|
||||||
|
driver_number: 11,
|
||||||
|
name_acronym: 'PER',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
points: 60,
|
||||||
|
teammate_wins: 1,
|
||||||
|
teammate_losses: 9,
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 99,
|
||||||
|
name_acronym: 'LAW',
|
||||||
|
full_name: 'Liam Lawson',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
points: 10,
|
||||||
|
teammate_wins: 0,
|
||||||
|
teammate_losses: 0,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const pairs = teammatePairs(drivers)
|
||||||
|
expect(pairs).toHaveLength(1)
|
||||||
|
expect(pairs[0].driverA.name_acronym).toBe('VER')
|
||||||
|
expect(pairs[0].driverB.name_acronym).toBe('PER')
|
||||||
|
expect(pairs[0].extraCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts by closest battle first', () => {
|
||||||
|
const drivers = [
|
||||||
|
driver({
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
points: 200,
|
||||||
|
teammate_wins: 10,
|
||||||
|
teammate_losses: 0,
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 11,
|
||||||
|
name_acronym: 'PER',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
points: 60,
|
||||||
|
teammate_wins: 0,
|
||||||
|
teammate_losses: 10,
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 160,
|
||||||
|
teammate_wins: 6,
|
||||||
|
teammate_losses: 5,
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 81,
|
||||||
|
name_acronym: 'PIA',
|
||||||
|
full_name: 'Oscar Piastri',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 140,
|
||||||
|
teammate_wins: 5,
|
||||||
|
teammate_losses: 6,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const pairs = teammatePairs(drivers)
|
||||||
|
expect(pairs).toHaveLength(2)
|
||||||
|
expect(pairs[0].teamName).toBe('McLaren')
|
||||||
|
expect(pairs[0].closeness).toBe(1)
|
||||||
|
expect(pairs[1].teamName).toBe('Red Bull')
|
||||||
|
expect(pairs[1].closeness).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
bestLapGaps,
|
||||||
compoundClass,
|
compoundClass,
|
||||||
compoundLetter,
|
compoundLetter,
|
||||||
extrapolateClock,
|
extrapolateClock,
|
||||||
|
isLiveSessionActive,
|
||||||
latestRaceControl,
|
latestRaceControl,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
mergeVisibleSectors,
|
mergeVisibleSectors,
|
||||||
|
parseLapTimeSeconds,
|
||||||
positionDeltaClass,
|
positionDeltaClass,
|
||||||
parseLiveStateEvent,
|
parseLiveStateEvent,
|
||||||
rcFlagClass,
|
rcFlagClass,
|
||||||
@@ -279,6 +282,43 @@ describe('live qualifying display', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('practice/qualifying computed gaps', () => {
|
||||||
|
it('parses lap-time strings into seconds and rejects invalid input', () => {
|
||||||
|
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
|
||||||
|
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
|
||||||
|
expect(parseLapTimeSeconds('')).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds(undefined)).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds('-')).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds('nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
|
||||||
|
const gaps = bestLapGaps([
|
||||||
|
timingRow('1', 1, { BestLapTime: '1:45.944' }),
|
||||||
|
timingRow('4', 2, { BestLapTime: '1:46.134' }),
|
||||||
|
timingRow('16', 3, { BestLapTime: '' }),
|
||||||
|
])
|
||||||
|
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
|
||||||
|
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
|
||||||
|
// No valid best lap → no fabricated gap.
|
||||||
|
expect(gaps['16']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no gaps when nobody has set a lap', () => {
|
||||||
|
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('live session activity', () => {
|
||||||
|
it('is active only when the feed reports a live session with data', () => {
|
||||||
|
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
|
||||||
|
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
|
||||||
|
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
|
||||||
|
expect(isLiveSessionActive(null)).toBe(false)
|
||||||
|
expect(isLiveSessionActive(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('visible sector display', () => {
|
describe('visible sector display', () => {
|
||||||
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
||||||
const first = [timingRow('4', 1, {
|
const first = [timingRow('4', 1, {
|
||||||
|
|||||||
227
frontend/src/test/preview.test.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||||
|
import {
|
||||||
|
buildTitleFightContext,
|
||||||
|
countdownTargetSession,
|
||||||
|
extractPodium,
|
||||||
|
extractPole,
|
||||||
|
findPriorYearMeetingByCircuit,
|
||||||
|
findRaceSession,
|
||||||
|
formatPointsGap,
|
||||||
|
isSprintWeekend,
|
||||||
|
pickPreviewMeeting,
|
||||||
|
RACE_WIN_POINTS,
|
||||||
|
SPRINT_WEEKEND_MAX_POINTS,
|
||||||
|
} from '../lib/preview'
|
||||||
|
|
||||||
|
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||||
|
meeting_key: 1,
|
||||||
|
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: '2026-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2026-05-24T23:59:59+00:00',
|
||||||
|
year: 2026,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||||
|
session_key: 100,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2026-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('preview lib', () => {
|
||||||
|
it('picks the next upcoming meeting', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const meetings = [
|
||||||
|
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
|
||||||
|
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no upcoming meeting exists', () => {
|
||||||
|
const now = new Date('2027-01-01T00:00:00Z')
|
||||||
|
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips cancelled meetings', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const meetings = [meeting({ is_cancelled: true })]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches prior-year meeting by circuit_key', () => {
|
||||||
|
const prior = [
|
||||||
|
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
|
||||||
|
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
|
||||||
|
]
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds the grand prix race session', () => {
|
||||||
|
const sessions = [
|
||||||
|
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
|
||||||
|
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
|
||||||
|
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
expect(findRaceSession(sessions)?.session_key).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects sprint weekends', () => {
|
||||||
|
const sprint = [
|
||||||
|
session({ session_type: 'Practice' }),
|
||||||
|
session({ session_name: 'Sprint', session_type: 'Sprint' }),
|
||||||
|
session({ session_type: 'Race' }),
|
||||||
|
]
|
||||||
|
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
|
||||||
|
expect(isSprintWeekend(sprint)).toBe(true)
|
||||||
|
expect(isSprintWeekend(normal)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts podium and pole', () => {
|
||||||
|
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: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 2,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 101,
|
||||||
|
gap_to_leader: 1,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 18,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 16,
|
||||||
|
position: 3,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'e8002d',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 102,
|
||||||
|
gap_to_leader: 2,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 15,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const grid: EnrichedGrid[] = [
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
lap_duration: 70,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const podium = extractPodium(results)
|
||||||
|
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
|
||||||
|
expect(extractPole(grid)?.name_acronym).toBe('NOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds title fight gaps for top 3', () => {
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2026,
|
||||||
|
round: 5,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 19,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1'],
|
||||||
|
drivers: [
|
||||||
|
hubDriver({ points: 200, position: 1 }),
|
||||||
|
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||||
|
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = buildTitleFightContext(hub)
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].gapToLeader).toBe(0)
|
||||||
|
expect(rows[1].gapToLeader).toBe(40)
|
||||||
|
expect(rows[1].gapAfterRaceWin).toBe(15)
|
||||||
|
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
|
||||||
|
expect(RACE_WIN_POINTS).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats points gaps', () => {
|
||||||
|
expect(formatPointsGap(0)).toBe('LEADER')
|
||||||
|
expect(formatPointsGap(40)).toBe('+40')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks countdown target as next future session', () => {
|
||||||
|
const sessions = [
|
||||||
|
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
|
||||||
|
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
const now = new Date('2026-05-23T12:00:00Z')
|
||||||
|
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
frontend/src/test/replay.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ReplayFrame, TrackBounds } from '../types'
|
||||||
|
import { interpolateReplayCars, lookupReplayFramePair, replayCarToSvg } from '../lib/replay'
|
||||||
|
|
||||||
|
const frames: ReplayFrame[] = [
|
||||||
|
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
|
||||||
|
{ t: 5000, cars: { '1': { x: 10, y: 20 }, '4': { x: 40, y: 80 } } },
|
||||||
|
{ t: 10000, cars: { '1': { x: 20, y: 40 } } },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('replay helpers', () => {
|
||||||
|
it('looks up boundary frame pairs', () => {
|
||||||
|
expect(lookupReplayFramePair(frames, -1)).toEqual({ previous: frames[0], next: frames[0] })
|
||||||
|
expect(lookupReplayFramePair(frames, 10000)).toEqual({ previous: frames[2], next: frames[2] })
|
||||||
|
expect(lookupReplayFramePair([], 1000)).toEqual({ previous: null, next: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates cars between frames and keeps one-sided samples visible', () => {
|
||||||
|
const cars = interpolateReplayCars(frames, 2500)
|
||||||
|
expect(cars['1']).toEqual({ x: 5, y: 10 })
|
||||||
|
expect(cars['4']).toEqual({ x: 40, y: 80 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps interpolation outside the replay range', () => {
|
||||||
|
expect(interpolateReplayCars(frames, -500)['1']).toEqual({ x: 0, y: 0 })
|
||||||
|
expect(interpolateReplayCars(frames, 12000)['1']).toEqual({ x: 20, y: 40 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps replay GPS through the shared track-map coordinate transform', () => {
|
||||||
|
const bounds: TrackBounds = { minX: 0, maxX: 100, minY: 0, maxY: 200 }
|
||||||
|
expect(replayCarToSvg({ x: 50, y: 50 }, bounds)).toEqual({ x: 50, y: 75 })
|
||||||
|
})
|
||||||
|
})
|
||||||
33
frontend/src/test/replayMap.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isReplayMapAvailable } from '../lib/replayMap'
|
||||||
|
import type { ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 1,
|
||||||
|
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||||
|
points: [{ x: 0, y: 0 }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay: ReplayFramesResponse = {
|
||||||
|
session_key: 1,
|
||||||
|
interval_ms: 5000,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
frames: [
|
||||||
|
{ t: 0, cars: {} },
|
||||||
|
{ t: 5000, cars: {} },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isReplayMapAvailable', () => {
|
||||||
|
it('returns true when frames and outline are present', () => {
|
||||||
|
expect(isReplayMapAvailable(replay, outline, false)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when frames are sparse', () => {
|
||||||
|
expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false on query error', () => {
|
||||||
|
expect(isReplayMapAvailable(replay, outline, true)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
71
frontend/src/test/rivalry.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||||
|
|
||||||
|
describe('gapSeries', () => {
|
||||||
|
it('computes per-round a − b', () => {
|
||||||
|
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles negative gaps (b ahead)', () => {
|
||||||
|
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates to the shorter series', () => {
|
||||||
|
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
|
||||||
|
expect(gapSeries([], [18, 36])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('h2hTally', () => {
|
||||||
|
it('tallies lower-position wins per round', () => {
|
||||||
|
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
|
||||||
|
expect(t.a).toBe(2)
|
||||||
|
expect(t.b).toBe(1)
|
||||||
|
expect(t.skipped).toBe(0)
|
||||||
|
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
|
||||||
|
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips rounds where either driver has no position', () => {
|
||||||
|
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
|
||||||
|
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
|
||||||
|
expect(t.a).toBe(1)
|
||||||
|
expect(t.b).toBe(1)
|
||||||
|
expect(t.skipped).toBe(2)
|
||||||
|
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles arrays of different lengths (missing tail = skipped)', () => {
|
||||||
|
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
|
||||||
|
expect(t.a).toBe(1)
|
||||||
|
expect(t.b).toBe(0)
|
||||||
|
expect(t.skipped).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips equal positions defensively and falls back on labels', () => {
|
||||||
|
const t = h2hTally([2, 1], [2, 3], ['R1'])
|
||||||
|
expect(t.a).toBe(1)
|
||||||
|
expect(t.b).toBe(0)
|
||||||
|
expect(t.skipped).toBe(1)
|
||||||
|
expect(t.rounds[0].label).toBe('R2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty tally for empty inputs', () => {
|
||||||
|
const t = h2hTally([], [], [])
|
||||||
|
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lastRounds', () => {
|
||||||
|
it('returns the last n counted rounds in order', () => {
|
||||||
|
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
|
||||||
|
const last = lastRounds(t, 5)
|
||||||
|
expect(last).toHaveLength(5)
|
||||||
|
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fewer when the tally has fewer counted rounds', () => {
|
||||||
|
const t = h2hTally([1, 0], [2, 0], [])
|
||||||
|
expect(lastRounds(t, 5)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
focusMeetingKind,
|
focusMeetingKind,
|
||||||
focusMeetingLabel,
|
focusMeetingLabel,
|
||||||
formatCountdown,
|
formatCountdown,
|
||||||
|
MAX_BROWSER_TIMEOUT,
|
||||||
nextUpcomingMeeting,
|
nextUpcomingMeeting,
|
||||||
pickFocusMeeting,
|
pickFocusMeeting,
|
||||||
|
refreshDeadlineDelay,
|
||||||
} from '../lib/schedule'
|
} from '../lib/schedule'
|
||||||
import type { Meeting, Session } from '../types'
|
import type { Meeting, Session } from '../types'
|
||||||
|
|
||||||
@@ -78,4 +80,15 @@ describe('schedule helpers', () => {
|
|||||||
const target = new Date('2025-05-25T13:00:00+00:00')
|
const target = new Date('2025-05-25T13:00:00+00:00')
|
||||||
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
|
expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the server refresh deadline without local timezone conversion', () => {
|
||||||
|
expect(refreshDeadlineDelay('2025-05-25T13:00:00Z', Date.parse('2025-05-25T12:59:30Z'))).toBe(30_000)
|
||||||
|
expect(refreshDeadlineDelay(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps a refresh deadline beyond the browser timer maximum', () => {
|
||||||
|
const now = Date.parse('2025-05-25T12:00:00Z')
|
||||||
|
const deadline = new Date(now + MAX_BROWSER_TIMEOUT + 1_000).toISOString()
|
||||||
|
expect(refreshDeadlineDelay(deadline, now)).toBe(MAX_BROWSER_TIMEOUT)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 0,
|
poles: 0,
|
||||||
form: [],
|
form: [],
|
||||||
cumulative: [],
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
teammate_wins: 0,
|
teammate_wins: 0,
|
||||||
teammate_losses: 0,
|
teammate_losses: 0,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Meeting {
|
|||||||
country_name: string
|
country_name: string
|
||||||
country_code: string
|
country_code: string
|
||||||
country_flag: string
|
country_flag: string
|
||||||
|
circuit_key?: number
|
||||||
circuit_short_name: string
|
circuit_short_name: string
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -23,6 +24,7 @@ export interface Session {
|
|||||||
session_key: number
|
session_key: number
|
||||||
session_name: string
|
session_name: string
|
||||||
session_type: string
|
session_type: string
|
||||||
|
circuit_key?: number
|
||||||
meeting_key: number
|
meeting_key: number
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -95,6 +97,7 @@ export interface RaceHub {
|
|||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||||
title: string
|
title: string
|
||||||
|
headline: string
|
||||||
start_lap: number
|
start_lap: number
|
||||||
end_lap: number
|
end_lap: number
|
||||||
start_time?: string
|
start_time?: string
|
||||||
@@ -214,6 +217,41 @@ export interface Weekend {
|
|||||||
default_session_key?: number
|
default_session_key?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextAvailability {
|
||||||
|
source: string
|
||||||
|
schedule: string
|
||||||
|
live_transport: string
|
||||||
|
live_session: string
|
||||||
|
archive: string
|
||||||
|
local_analysis: string
|
||||||
|
freshness: string
|
||||||
|
observed_at?: string
|
||||||
|
limitations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextSession {
|
||||||
|
session: Session
|
||||||
|
meeting?: Meeting
|
||||||
|
availability: ContextAvailability
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekendContext {
|
||||||
|
season?: number
|
||||||
|
temporal_state: string
|
||||||
|
previous_meeting?: Meeting
|
||||||
|
focus_meeting?: Meeting
|
||||||
|
next_meeting?: Meeting
|
||||||
|
previous_completed_session?: ContextSession
|
||||||
|
active_session?: ContextSession
|
||||||
|
next_session?: ContextSession
|
||||||
|
default_analysis_session?: ContextSession
|
||||||
|
race_hub_default_session?: ContextSession
|
||||||
|
race_hub_pre_session: boolean
|
||||||
|
race_hub_refresh_at?: string
|
||||||
|
championship_round: number
|
||||||
|
total_championship_rounds: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveStateResponse {
|
export interface LiveStateResponse {
|
||||||
is_live: boolean
|
is_live: boolean
|
||||||
data: LiveStreamData | null
|
data: LiveStreamData | null
|
||||||
@@ -358,6 +396,23 @@ export interface TrackOutline {
|
|||||||
bounds: TrackBounds
|
bounds: TrackBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplayCarPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFrame {
|
||||||
|
t: number
|
||||||
|
cars: Record<string, ReplayCarPosition>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFramesResponse {
|
||||||
|
session_key: number
|
||||||
|
interval_ms: number
|
||||||
|
start_time: string
|
||||||
|
frames: ReplayFrame[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChampHubDriver {
|
export interface ChampHubDriver {
|
||||||
driver_number: number
|
driver_number: number
|
||||||
name_acronym: string
|
name_acronym: string
|
||||||
@@ -371,6 +426,7 @@ export interface ChampHubDriver {
|
|||||||
poles: number
|
poles: number
|
||||||
form: number[]
|
form: number[]
|
||||||
cumulative: number[]
|
cumulative: number[]
|
||||||
|
round_positions: number[]
|
||||||
teammate_wins: number
|
teammate_wins: number
|
||||||
teammate_losses: number
|
teammate_losses: number
|
||||||
}
|
}
|
||||||
@@ -394,6 +450,39 @@ export interface ChampionshipHub {
|
|||||||
teams: ChampHubTeam[]
|
teams: ChampHubTeam[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DriverSummaryRound {
|
||||||
|
meeting_key: number
|
||||||
|
meeting_name: string
|
||||||
|
country_code: string
|
||||||
|
country_name: string
|
||||||
|
race_position: number
|
||||||
|
grid_position: number
|
||||||
|
quali_position?: number
|
||||||
|
points: number
|
||||||
|
dnf: boolean
|
||||||
|
dns: boolean
|
||||||
|
dsq: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverSummary {
|
||||||
|
season: number
|
||||||
|
driver_number: number
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
headshot_url: string
|
||||||
|
points: number
|
||||||
|
position: number
|
||||||
|
wins: number
|
||||||
|
podiums: number
|
||||||
|
poles: number
|
||||||
|
form: number[]
|
||||||
|
cumulative: number[]
|
||||||
|
round_labels: string[]
|
||||||
|
rounds: DriverSummaryRound[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewsItem {
|
export interface NewsItem {
|
||||||
source: string
|
source: string
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
|||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
age := time.Since(time.Unix(createdAt, 0))
|
age := time.Since(time.Unix(createdAt, 0))
|
||||||
if age > ttl {
|
if age > ttl {
|
||||||
// Expired — delete and return miss.
|
// Expired entries remain stored so get() can use them as a stale
|
||||||
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
// fallback if the live request fails. Prune() owns physical cleanup.
|
||||||
atomic.AddInt64(&c.stats.Misses, 1)
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -26,8 +27,12 @@ type requestPacer struct {
|
|||||||
|
|
||||||
// wait blocks until this caller's reserved slot arrives.
|
// wait blocks until this caller's reserved slot arrives.
|
||||||
func (p *requestPacer) wait() {
|
func (p *requestPacer) wait() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *requestPacer) waitContext(ctx context.Context) error {
|
||||||
if p == nil || p.interval <= 0 {
|
if p == nil || p.interval <= 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -38,9 +43,19 @@ func (p *requestPacer) wait() {
|
|||||||
p.next = p.next.Add(p.interval)
|
p.next = p.next.Add(p.interval)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if sleep > 0 {
|
if sleep > 0 {
|
||||||
time.Sleep(sleep)
|
timer := time.NewTimer(sleep)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Keep the unused reservation in the schedule. Blindly reclaiming an
|
||||||
|
// interval can collide with later callers that already reserved their
|
||||||
|
// wake times, releasing two requests simultaneously.
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type OpenF1Client struct {
|
type OpenF1Client struct {
|
||||||
url string
|
url string
|
||||||
@@ -56,6 +71,26 @@ type OpenF1Client struct {
|
|||||||
staleFlag int32
|
staleFlag int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scoped returns a lightweight request-scoped view of the client. Network,
|
||||||
|
// pacing and cache resources are shared, while the stale fallback indicator is
|
||||||
|
// deliberately not shared. Web handlers use this view so a stale fallback in
|
||||||
|
// one concurrent HTTP request can never mark an unrelated response as stale.
|
||||||
|
//
|
||||||
|
// The legacy client-wide stale flag remains available for the TUI, whose loads
|
||||||
|
// are intentionally aggregated into one navigation-level notice.
|
||||||
|
func (c *OpenF1Client) Scoped() *OpenF1Client {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &OpenF1Client{
|
||||||
|
url: c.url,
|
||||||
|
apiKey: c.apiKey,
|
||||||
|
httpClient: c.httpClient,
|
||||||
|
cache: c.cache,
|
||||||
|
pacer: c.pacer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
||||||
return &OpenF1Client{
|
return &OpenF1Client{
|
||||||
url: url,
|
url: url,
|
||||||
|
|||||||
123
internal/api/freshness_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) {
|
||||||
|
year := time.Now().Year()
|
||||||
|
var fail atomic.Bool
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if fail.Load() {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = fmt.Fprintf(w, `[{
|
||||||
|
"meeting_key": 1,
|
||||||
|
"meeting_name": "British Grand Prix",
|
||||||
|
"year": %d
|
||||||
|
}]`, year)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
client := NewOpenF1Client(upstream.URL, time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
client.pacer.interval = 0
|
||||||
|
key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||||
|
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
||||||
|
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }()
|
||||||
|
|
||||||
|
if _, err := client.GetMeetingsForYear(year); err != nil {
|
||||||
|
t.Fatalf("prime cache: %v", err)
|
||||||
|
}
|
||||||
|
expireCacheEntry(t, client, key)
|
||||||
|
fail.Store(true)
|
||||||
|
|
||||||
|
scoped := client.Scoped()
|
||||||
|
meetings, err := scoped.GetMeetingsForYear(year)
|
||||||
|
if err != nil || len(meetings) != 1 {
|
||||||
|
t.Fatalf("stale fallback = (%+v, %v)", meetings, err)
|
||||||
|
}
|
||||||
|
if !scoped.LastResponseWasStale() {
|
||||||
|
t.Fatal("scoped request did not report its stale fallback")
|
||||||
|
}
|
||||||
|
if client.LastResponseWasStale() {
|
||||||
|
t.Fatal("request-scoped fallback leaked into the parent client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) {
|
||||||
|
year := time.Now().Year()
|
||||||
|
staleStarted := make(chan struct{})
|
||||||
|
releaseStale := make(chan struct{})
|
||||||
|
var failMeetings atomic.Bool
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/meetings":
|
||||||
|
if failMeetings.Load() {
|
||||||
|
close(staleStarted)
|
||||||
|
<-releaseStale
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
|
||||||
|
case "/v1/sessions":
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
client := NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
client.pacer.interval = 0
|
||||||
|
meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||||
|
sessionKey := upstream.URL + "/v1/sessions?meeting_key=2"
|
||||||
|
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey)
|
||||||
|
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }()
|
||||||
|
if _, err := client.GetMeetingsForYear(year); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expireCacheEntry(t, client, meetingKey)
|
||||||
|
failMeetings.Store(true)
|
||||||
|
|
||||||
|
staleClient := client.Scoped()
|
||||||
|
staleDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := staleClient.GetMeetingsForYear(year)
|
||||||
|
staleDone <- err
|
||||||
|
}()
|
||||||
|
<-staleStarted
|
||||||
|
|
||||||
|
freshClient := client.Scoped()
|
||||||
|
if _, err := freshClient.GetSessionsForMeeting(2); err != nil {
|
||||||
|
t.Fatalf("fresh concurrent request: %v", err)
|
||||||
|
}
|
||||||
|
if freshClient.LastResponseWasStale() {
|
||||||
|
t.Fatal("fresh request inherited concurrent request's stale state")
|
||||||
|
}
|
||||||
|
close(releaseStale)
|
||||||
|
if err := <-staleDone; err != nil {
|
||||||
|
t.Fatalf("stale request: %v", err)
|
||||||
|
}
|
||||||
|
if !staleClient.LastResponseWasStale() {
|
||||||
|
t.Fatal("stale request lost its own freshness state")
|
||||||
|
}
|
||||||
|
if freshClient.LastResponseWasStale() || client.LastResponseWasStale() {
|
||||||
|
t.Fatal("stale state leaked after concurrent requests completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -59,8 +60,14 @@ func retryAfter429(resp *http.Response) time.Duration {
|
|||||||
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
|
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
|
||||||
// past the free-tier limit and callers silently treat 429s as missing data.
|
// past the free-tier limit and callers silently treat 429s as missing data.
|
||||||
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
||||||
|
return c.doPacedContext(req.Context(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenF1Client) doPacedContext(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||||
for attempt := 0; ; attempt++ {
|
for attempt := 0; ; attempt++ {
|
||||||
c.pacer.wait()
|
if err := c.pacer.waitContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -70,7 +77,14 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
delay := retryAfter429(resp)
|
delay := retryAfter429(resp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
time.Sleep(delay)
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +97,23 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
|||||||
// entry for this URL, that stale entry is returned instead of propagating the
|
// entry for this URL, that stale entry is returned instead of propagating the
|
||||||
// error. The client's staleFlag is set so the UI can show a disclaimer.
|
// error. The client's staleFlag is set so the UI can show a disclaimer.
|
||||||
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
||||||
|
return c.getContext(context.Background(), url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContext is the cancellable form used by bounded optional web enrichment.
|
||||||
|
// A caller cancellation never falls back to stale data: the work is no longer
|
||||||
|
// relevant to that response and must stop instead of continuing in background.
|
||||||
|
func (c *OpenF1Client) getContext(ctx context.Context, url string) (io.ReadCloser, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// 1. Check the cache for a fresh (non-expired) entry.
|
// 1. Check the cache for a fresh (non-expired) entry.
|
||||||
if cachedData, ok := c.cache.Get(url); ok {
|
if cachedData, ok := c.cache.Get(url); ok {
|
||||||
return io.NopCloser(bytes.NewReader(cachedData)), nil
|
return io.NopCloser(bytes.NewReader(cachedData)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Attempt a live network request.
|
// 2. Attempt a live network request.
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Even a request-construction failure warrants a stale fallback.
|
// Even a request-construction failure warrants a stale fallback.
|
||||||
return c.tryStale(url, err)
|
return c.tryStale(url, err)
|
||||||
@@ -98,8 +122,11 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.doPaced(req)
|
resp, err := c.doPacedContext(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
return c.tryStale(url, err)
|
return c.tryStale(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -128,6 +155,9 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
|||||||
// 3. Success — read the body, store in cache, return.
|
// 3. Success — read the body, store in cache, return.
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
return c.tryStale(url, err)
|
return c.tryStale(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +275,13 @@ func (c *OpenF1Client) GetDriversForSession(sessionKey int) ([]models.Driver, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
|
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
|
||||||
body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
|
return c.GetDriverContext(context.Background(), sessionKey, driverNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDriverContext is a cancellable single-driver lookup for optional bounded
|
||||||
|
// enrichment. Other public methods retain their existing background semantics.
|
||||||
|
func (c *OpenF1Client) GetDriverContext(ctx context.Context, sessionKey, driverNumber int) (*models.Driver, error) {
|
||||||
|
body, err := c.getContext(ctx, fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -49,6 +51,70 @@ func TestRequestPacerNilSafe(t *testing.T) {
|
|||||||
p.wait() // must not panic
|
p.wait() // must not panic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
|
||||||
|
const interval = 80 * time.Millisecond
|
||||||
|
p := &requestPacer{interval: interval}
|
||||||
|
if err := p.waitContext(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
initialNext := p.next
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
waitForReservation := func(want time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(250 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
p.mu.Lock()
|
||||||
|
got := p.next
|
||||||
|
p.mu.Unlock()
|
||||||
|
if got.Equal(want) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("reservation did not reach %v", want)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxB, cancelB := context.WithCancel(context.Background())
|
||||||
|
bDone := make(chan error, 1)
|
||||||
|
go func() { bDone <- p.waitContext(ctxB) }()
|
||||||
|
waitForReservation(initialNext.Add(interval))
|
||||||
|
|
||||||
|
cDone := make(chan time.Time, 1)
|
||||||
|
go func() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
cDone <- time.Now()
|
||||||
|
}()
|
||||||
|
waitForReservation(initialNext.Add(2 * interval))
|
||||||
|
|
||||||
|
cancelStarted := time.Now()
|
||||||
|
cancelB()
|
||||||
|
select {
|
||||||
|
case err := <-bDone:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("B error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(cancelStarted); elapsed > 30*time.Millisecond {
|
||||||
|
t.Fatalf("B cancellation took %v", elapsed)
|
||||||
|
}
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
t.Fatal("B did not return promptly after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
dDone := make(chan time.Time, 1)
|
||||||
|
go func() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
dDone <- time.Now()
|
||||||
|
}()
|
||||||
|
waitForReservation(initialNext.Add(3 * interval))
|
||||||
|
|
||||||
|
cAt, dAt := <-cDone, <-dDone
|
||||||
|
if separation := dAt.Sub(cAt); separation < interval/2 {
|
||||||
|
t.Fatalf("C and D collided: wake separation %v, want at least %v", separation, interval/2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetRetriesOn429(t *testing.T) {
|
func TestGetRetriesOn429(t *testing.T) {
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Lap = models.Lap
|
|||||||
type Chapter struct {
|
type Chapter struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Headline string `json:"headline"`
|
||||||
StartLap int `json:"start_lap"`
|
StartLap int `json:"start_lap"`
|
||||||
EndLap int `json:"end_lap"`
|
EndLap int `json:"end_lap"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
|||||||
233
internal/chapters/headlines.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DriverIdentityInput carries session driver fields used for headline templates.
|
||||||
|
type DriverIdentityInput struct {
|
||||||
|
DriverNumber int
|
||||||
|
NameAcronym string
|
||||||
|
FullName string
|
||||||
|
TeamName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type driverIdentity struct {
|
||||||
|
display string
|
||||||
|
team string
|
||||||
|
acronym string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDriverMap indexes driver identity from session drivers and enriched results.
|
||||||
|
func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
|
||||||
|
out := map[int]driverIdentity{}
|
||||||
|
for _, d := range drivers {
|
||||||
|
if d.DriverNumber <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[d.DriverNumber] = driverIdentity{
|
||||||
|
display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
|
||||||
|
team: d.TeamName,
|
||||||
|
acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
if r.DriverNumber <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := out[r.DriverNumber]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.DriverNumber] = driverIdentity{
|
||||||
|
display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
|
||||||
|
team: r.TeamName,
|
||||||
|
acronym: r.NameAcronym,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyHeadlines fills Headline on each chapter using deterministic templates.
|
||||||
|
func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
|
||||||
|
out := make([]Chapter, len(chapters))
|
||||||
|
copy(out, chapters)
|
||||||
|
for i := range out {
|
||||||
|
out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
|
||||||
|
switch ch.Kind {
|
||||||
|
case KindStart:
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
"Lights out — the field charges into Turn 1",
|
||||||
|
"Race start — Lap 1 shuffle at the front",
|
||||||
|
)
|
||||||
|
case KindSafetyCar:
|
||||||
|
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
|
||||||
|
fmt.Sprintf("Safety Car deployed after %s stops on track", name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case KindVirtualSafetyCar:
|
||||||
|
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
|
||||||
|
fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
|
||||||
|
fmt.Sprintf("VSC period — field backs off on L%d–L%d", ch.StartLap, ch.EndLap),
|
||||||
|
)
|
||||||
|
case KindRedFlag:
|
||||||
|
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap),
|
||||||
|
fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap),
|
||||||
|
fmt.Sprintf("Race suspended under red flag on L%d–L%d", ch.StartLap, ch.EndLap),
|
||||||
|
)
|
||||||
|
case KindPitPhase:
|
||||||
|
names := driverNames(ch.DriverNumbers, drivers, 3)
|
||||||
|
if len(names) >= 2 {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%d–L%d", names[0], names[1], ch.StartLap, ch.EndLap),
|
||||||
|
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%d–L%d", names[0], ch.StartLap, ch.EndLap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if len(names) == 1 {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap),
|
||||||
|
fmt.Sprintf("Pit phase on L%d–L%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case KindDecisiveSwing:
|
||||||
|
if len(ch.DriverNumbers) >= 2 {
|
||||||
|
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
|
||||||
|
defender := driverName(ch, drivers, ch.DriverNumbers[1])
|
||||||
|
pos := swingPosition(ch.Title)
|
||||||
|
if attacker != "" && defender != "" && pos > 0 {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos),
|
||||||
|
fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ch.DriverNumbers) >= 1 {
|
||||||
|
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
|
||||||
|
pos := swingPosition(ch.Title)
|
||||||
|
if attacker != "" && pos > 0 {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap),
|
||||||
|
fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case KindFinish:
|
||||||
|
winner := driverName(ch, drivers, winnerNumber)
|
||||||
|
if winner != "" {
|
||||||
|
return pickVariant(ch.StartLap,
|
||||||
|
fmt.Sprintf("%s crosses the line to take the win", winner),
|
||||||
|
fmt.Sprintf("Chequered flag — %s wins the race", winner),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ch.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickVariant(seed int, variants ...string) string {
|
||||||
|
if len(variants) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if seed < 0 {
|
||||||
|
seed = -seed
|
||||||
|
}
|
||||||
|
return variants[seed%len(variants)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string {
|
||||||
|
if number <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if id, ok := drivers[number]; ok && id.display != "" {
|
||||||
|
return id.display
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string {
|
||||||
|
out := make([]string, 0, limit)
|
||||||
|
for _, number := range numbers {
|
||||||
|
if name := driverName(Chapter{}, drivers, number); name != "" {
|
||||||
|
out = append(out, name)
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverDisplayName(lastName, fullName, acronym, broadcast string) string {
|
||||||
|
if lastName != "" {
|
||||||
|
return lastName
|
||||||
|
}
|
||||||
|
if fullName != "" {
|
||||||
|
parts := strings.Fields(fullName)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
return fullName
|
||||||
|
}
|
||||||
|
return firstNonEmpty(acronym, broadcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
func incidentDriver(rc []RaceControl, startLap int) int {
|
||||||
|
for _, msg := range rc {
|
||||||
|
if msg.DriverNumber == nil || *msg.DriverNumber <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := 0
|
||||||
|
if msg.LapNumber != nil {
|
||||||
|
lap = *msg.LapNumber
|
||||||
|
}
|
||||||
|
if lap < startLap-1 || lap > startLap+1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := upperText(msg.Message, string(msg.Category))
|
||||||
|
if strings.Contains(text, "DEPLOY") ||
|
||||||
|
strings.Contains(text, "CLEAR") ||
|
||||||
|
strings.Contains(text, "ENDING") ||
|
||||||
|
strings.Contains(text, "GREEN") ||
|
||||||
|
strings.Contains(text, "CHEQUER") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return *msg.DriverNumber
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func swingPosition(title string) int {
|
||||||
|
// Title format: "Decisive swing: #16 to P3 (L6)"
|
||||||
|
idx := strings.Index(title, "P")
|
||||||
|
if idx < 0 || idx+1 >= len(title) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pos := 0
|
||||||
|
for i := idx + 1; i < len(title); i++ {
|
||||||
|
if title[i] < '0' || title[i] > '9' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos = pos*10 + int(title[i]-'0')
|
||||||
|
}
|
||||||
|
return pos
|
||||||
|
}
|
||||||
199
internal/chapters/headlines_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeadlineStart(t *testing.T) {
|
||||||
|
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
|
||||||
|
got := headlineFor(ch, nil, nil, 0)
|
||||||
|
want := "Race start — Lap 1 shuffle at the front"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineStartVariantByLap(t *testing.T) {
|
||||||
|
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
|
||||||
|
got := headlineFor(ch, nil, nil, 0)
|
||||||
|
want := "Lights out — the field charges into Turn 1"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineSafetyCarWithDriver(t *testing.T) {
|
||||||
|
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||||
|
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
||||||
|
})
|
||||||
|
rc := []RaceControl{
|
||||||
|
{
|
||||||
|
DriverNumber: intPtr(55),
|
||||||
|
LapNumber: intPtr(12),
|
||||||
|
Message: "CAR 55 STOPPED ON TRACK",
|
||||||
|
},
|
||||||
|
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
}
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindSafetyCar,
|
||||||
|
Title: "Safety Car (L12-L15)",
|
||||||
|
StartLap: 12,
|
||||||
|
EndLap: 15,
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, drivers, rc, 0)
|
||||||
|
want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindSafetyCar,
|
||||||
|
Title: "Safety Car (L12-L15)",
|
||||||
|
StartLap: 12,
|
||||||
|
EndLap: 15,
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, nil, nil, 0)
|
||||||
|
if got != ch.Title {
|
||||||
|
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineVirtualSafetyCar(t *testing.T) {
|
||||||
|
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||||
|
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||||
|
})
|
||||||
|
rc := []RaceControl{
|
||||||
|
{
|
||||||
|
DriverNumber: intPtr(16),
|
||||||
|
LapNumber: intPtr(22),
|
||||||
|
Message: "CAR 16 OFF TRACK",
|
||||||
|
},
|
||||||
|
rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||||
|
rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||||
|
}
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindVirtualSafetyCar,
|
||||||
|
Title: "Virtual Safety Car (L22-L24)",
|
||||||
|
StartLap: 22,
|
||||||
|
EndLap: 24,
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, drivers, rc, 0)
|
||||||
|
want := "Leclerc off track triggers the Virtual Safety Car"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineRedFlag(t *testing.T) {
|
||||||
|
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||||
|
{DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
|
||||||
|
})
|
||||||
|
rc := []RaceControl{
|
||||||
|
{
|
||||||
|
DriverNumber: intPtr(63),
|
||||||
|
LapNumber: intPtr(31),
|
||||||
|
Message: "INCIDENT INVOLVING CAR 63",
|
||||||
|
},
|
||||||
|
rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||||
|
rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||||
|
}
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindRedFlag,
|
||||||
|
Title: "Red Flag (L31-L33)",
|
||||||
|
StartLap: 31,
|
||||||
|
EndLap: 33,
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, drivers, rc, 0)
|
||||||
|
want := "Red flag after Russell incident on Lap 31"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlinePitPhase(t *testing.T) {
|
||||||
|
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||||
|
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
||||||
|
{DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
|
||||||
|
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||||
|
})
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindPitPhase,
|
||||||
|
Title: "Pit phase (L20-L22)",
|
||||||
|
StartLap: 20,
|
||||||
|
EndLap: 22,
|
||||||
|
DriverNumbers: []int{1, 44, 16},
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, drivers, nil, 0)
|
||||||
|
want := "Mass pit-window scramble — Verstappen and Hamilton box on L20–L22"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineDecisiveSwing(t *testing.T) {
|
||||||
|
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||||
|
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||||
|
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
||||||
|
})
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindDecisiveSwing,
|
||||||
|
Title: "Decisive swing: #16 to P3 (L6)",
|
||||||
|
StartLap: 6,
|
||||||
|
EndLap: 6,
|
||||||
|
DriverNumbers: []int{16, 55},
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, drivers, nil, 0)
|
||||||
|
want := "Leclerc overtakes Sainz for P3"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineFinish(t *testing.T) {
|
||||||
|
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||||
|
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
||||||
|
})
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindFinish,
|
||||||
|
Title: "Finish (L57-L58)",
|
||||||
|
StartLap: 57,
|
||||||
|
EndLap: 58,
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, drivers, nil, 1)
|
||||||
|
want := "Chequered flag — Verstappen wins the race"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("headline = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) {
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindFinish,
|
||||||
|
Title: "Finish (L57-L58)",
|
||||||
|
StartLap: 57,
|
||||||
|
EndLap: 58,
|
||||||
|
}
|
||||||
|
got := headlineFor(ch, nil, nil, 0)
|
||||||
|
if got != ch.Title {
|
||||||
|
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyHeadlinesPreservesChapterFields(t *testing.T) {
|
||||||
|
input := []Chapter{
|
||||||
|
{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1},
|
||||||
|
}
|
||||||
|
got := ApplyHeadlines(input, nil, nil, 0)
|
||||||
|
if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" {
|
||||||
|
t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionStatusIsTerminal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"Finished", true},
|
||||||
|
{"Finalised", true},
|
||||||
|
{"Finalized", true},
|
||||||
|
{"Ends", true},
|
||||||
|
{"Aborted", true},
|
||||||
|
{"Started", false},
|
||||||
|
{"Resumed", false},
|
||||||
|
{"Inactive", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
|
||||||
|
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
data := json.RawMessage(`{
|
data := json.RawMessage(`{
|
||||||
@@ -450,6 +472,62 @@ func TestProcessTopicTimingAppData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The feed sends stints as sparse deltas keyed by stint index. Replacing the
|
||||||
|
// slice on each delta collapsed pit history to one entry and pinned tyre age
|
||||||
|
// near zero — observed live at lap 49 of a 70-lap race, where every driver
|
||||||
|
// reported a single stint of age 0 despite having pitted.
|
||||||
|
func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) {
|
||||||
|
state := live.NewState()
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}}
|
||||||
|
}`))
|
||||||
|
// Stint 0 runs to 18 laps, then the driver pits onto a new hard.
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}}
|
||||||
|
}`))
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}}
|
||||||
|
}`))
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}}
|
||||||
|
}`))
|
||||||
|
|
||||||
|
snap := state.Snapshot()
|
||||||
|
stints := snap.Stints["4"]
|
||||||
|
if len(stints) != 2 {
|
||||||
|
t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints)
|
||||||
|
}
|
||||||
|
if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 {
|
||||||
|
t.Errorf("first stint lost across deltas: %+v", stints[0])
|
||||||
|
}
|
||||||
|
if stints[1].Compound != "HARD" || stints[1].Laps != 12 {
|
||||||
|
t.Errorf("second stint = %+v", stints[1])
|
||||||
|
}
|
||||||
|
if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 {
|
||||||
|
t.Errorf("current tyre should track the latest stint, got %+v", tyre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) {
|
||||||
|
state := live.NewState()
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}}
|
||||||
|
}`))
|
||||||
|
// "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0
|
||||||
|
// would overwrite the real first stint.
|
||||||
|
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||||
|
"Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}}
|
||||||
|
}`))
|
||||||
|
|
||||||
|
stints := state.Snapshot().Stints["4"]
|
||||||
|
if len(stints) != 1 {
|
||||||
|
t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints)
|
||||||
|
}
|
||||||
|
if stints[0].Compound != "SOFT" || stints[0].Laps != 9 {
|
||||||
|
t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessTopicTimingStats(t *testing.T) {
|
func TestProcessTopicTimingStats(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
|||||||
Stints json.RawMessage `json:"Stints"`
|
Stints json.RawMessage `json:"Stints"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
||||||
var driverStints []LiveStintData
|
// The feed sends stints as sparse deltas keyed by stint index:
|
||||||
|
// a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge
|
||||||
|
// each entry into the stint it addresses. Replacing the slice
|
||||||
|
// wholesale discarded every earlier stint, so pit history
|
||||||
|
// collapsed to one entry and tyre age stuck near zero for the
|
||||||
|
// whole race.
|
||||||
|
driverStints := append([]LiveStintData(nil), s.Stints[num]...)
|
||||||
|
changed := false
|
||||||
for _, sRaw := range indexedRawValues(line.Stints) {
|
for _, sRaw := range indexedRawValues(line.Stints) {
|
||||||
var st struct {
|
var st struct {
|
||||||
Compound string `json:"Compound"`
|
Compound *string `json:"Compound"`
|
||||||
New string `json:"New"`
|
New *string `json:"New"`
|
||||||
TotalLaps int `json:"TotalLaps"`
|
TotalLaps *int `json:"TotalLaps"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
|
if json.Unmarshal(sRaw.Raw, &st) != nil {
|
||||||
driverStints = append(driverStints, LiveStintData{
|
continue
|
||||||
Compound: st.Compound,
|
|
||||||
New: st.New == "true" || st.New == "True",
|
|
||||||
Laps: st.TotalLaps,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if st.Compound == nil && st.New == nil && st.TotalLaps == nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if len(driverStints) > 0 {
|
for len(driverStints) <= sRaw.Index {
|
||||||
|
driverStints = append(driverStints, LiveStintData{})
|
||||||
|
}
|
||||||
|
entry := &driverStints[sRaw.Index]
|
||||||
|
if st.Compound != nil && *st.Compound != "" {
|
||||||
|
entry.Compound = *st.Compound
|
||||||
|
}
|
||||||
|
if st.New != nil {
|
||||||
|
entry.New = *st.New == "true" || *st.New == "True"
|
||||||
|
}
|
||||||
|
if st.TotalLaps != nil {
|
||||||
|
entry.Laps = *st.TotalLaps
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
s.Stints[num] = driverStints
|
s.Stints[num] = driverStints
|
||||||
lastStint := driverStints[len(driverStints)-1]
|
lastStint := driverStints[len(driverStints)-1]
|
||||||
t := s.Tyres[num]
|
t := s.Tyres[num]
|
||||||
@@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
|||||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||||
values := make([]indexedRaw, 0, len(obj))
|
values := make([]indexedRaw, 0, len(obj))
|
||||||
for k, v := range obj {
|
for k, v := range obj {
|
||||||
i := 0
|
// Keys are array indices in the feed's delta form. Non-numeric keys
|
||||||
fmt.Sscanf(k, "%d", &i)
|
// are feed metadata — "_kf" (key frame) is the common one — and must
|
||||||
|
// not be folded in as index 0, which would clobber the first entry.
|
||||||
|
i, err := strconv.Atoi(k)
|
||||||
|
if err != nil || i < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
values = append(values, indexedRaw{Index: i, Raw: v})
|
values = append(values, indexedRaw{Index: i, Raw: v})
|
||||||
}
|
}
|
||||||
sort.Slice(values, func(i, j int) bool {
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
|||||||
@@ -186,6 +186,19 @@ func SessionStatusIsActive(status string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
|
||||||
|
// value represents a session that has ended and will not resume. A temporarily
|
||||||
|
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
|
||||||
|
// active nor terminal.
|
||||||
|
func SessionStatusIsTerminal(status string) bool {
|
||||||
|
switch normalizeSessionStatus(status) {
|
||||||
|
case "finished", "finalised", "finalized", "ends", "aborted":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeSessionStatus(status string) string {
|
func normalizeSessionStatus(status string) string {
|
||||||
out := make([]rune, 0, len(status))
|
out := make([]rune, 0, len(status))
|
||||||
for _, r := range status {
|
for _, r := range status {
|
||||||
|
|||||||
488
internal/query/context.go
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemporalState describes where the fan is in the current season/weekend.
|
||||||
|
type TemporalState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TemporalNoSeason TemporalState = "no_season"
|
||||||
|
TemporalBetweenWeekends TemporalState = "between_weekends"
|
||||||
|
TemporalPreSession TemporalState = "pre_session"
|
||||||
|
TemporalSessionLive TemporalState = "session_live"
|
||||||
|
TemporalSessionSettling TemporalState = "session_settling"
|
||||||
|
TemporalBetweenSessions TemporalState = "between_sessions"
|
||||||
|
TemporalPostWeekend TemporalState = "post_weekend"
|
||||||
|
TemporalSeasonComplete TemporalState = "season_complete"
|
||||||
|
|
||||||
|
preSessionWindow = 48 * time.Hour
|
||||||
|
postWeekendWindow = 48 * time.Hour
|
||||||
|
raceHubPendingPollInterval = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
||||||
|
// by the context resolver. Active identity is authoritative over the schedule.
|
||||||
|
type LiveEvidence struct {
|
||||||
|
Active bool
|
||||||
|
Final bool
|
||||||
|
MeetingName string
|
||||||
|
CircuitName string
|
||||||
|
SessionName string
|
||||||
|
SessionType string
|
||||||
|
ObservedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextAvailability is structured source state for a referenced session.
|
||||||
|
type ContextAvailability struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
LiveTransport string `json:"live_transport"`
|
||||||
|
LiveSession string `json:"live_session"`
|
||||||
|
Archive string `json:"archive"`
|
||||||
|
LocalAnalysis string `json:"local_analysis"`
|
||||||
|
Freshness string `json:"freshness"`
|
||||||
|
ObservedAt string `json:"observed_at,omitempty"`
|
||||||
|
Limitations []string `json:"limitations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextSession couples a session identity with its availability contract.
|
||||||
|
type ContextSession struct {
|
||||||
|
Session models.Session `json:"session"`
|
||||||
|
Meeting *models.Meeting `json:"meeting,omitempty"`
|
||||||
|
Availability ContextAvailability `json:"availability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekendContext is the canonical local-first previous/current/next model.
|
||||||
|
type WeekendContext struct {
|
||||||
|
Season int `json:"season,omitempty"`
|
||||||
|
TemporalState TemporalState `json:"temporal_state"`
|
||||||
|
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
|
||||||
|
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
|
||||||
|
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
|
||||||
|
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
|
||||||
|
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
||||||
|
NextSession *ContextSession `json:"next_session,omitempty"`
|
||||||
|
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
||||||
|
RaceHubDefaultSession *ContextSession `json:"race_hub_default_session,omitempty"`
|
||||||
|
RaceHubPreSession bool `json:"race_hub_pre_session"`
|
||||||
|
RaceHubRefreshAt string `json:"race_hub_refresh_at,omitempty"`
|
||||||
|
ChampionshipRound int `json:"championship_round"`
|
||||||
|
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextCandidate struct {
|
||||||
|
meeting store.Meeting
|
||||||
|
session store.Session
|
||||||
|
start time.Time
|
||||||
|
end time.Time
|
||||||
|
counts store.SessionDatasetCounts
|
||||||
|
complete bool
|
||||||
|
archived bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveWeekendContext computes the canonical context using only the domain
|
||||||
|
// store, the service clock, and optional in-memory FIA evidence.
|
||||||
|
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
|
||||||
|
now := s.now().UTC()
|
||||||
|
out := WeekendContext{TemporalState: TemporalNoSeason}
|
||||||
|
years, err := s.store.ListYears()
|
||||||
|
if err != nil || len(years) == 0 {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Season = currentLocalSeason(years, now.Year())
|
||||||
|
meetings, err := s.store.ListMeetingsByYear(out.Season)
|
||||||
|
if err != nil {
|
||||||
|
return WeekendContext{}, err
|
||||||
|
}
|
||||||
|
if len(meetings) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byMeeting := make(map[int][]store.Session, len(meetings))
|
||||||
|
var candidates []contextCandidate
|
||||||
|
for i := range meetings {
|
||||||
|
m := meetings[i]
|
||||||
|
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
|
||||||
|
if listErr != nil {
|
||||||
|
return WeekendContext{}, listErr
|
||||||
|
}
|
||||||
|
byMeeting[m.MeetingKey] = sessions
|
||||||
|
applySessionDisplayRange(&meetings[i], sessions)
|
||||||
|
m = meetings[i]
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if m.IsCancelled || sess.IsCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, _ := parseContextTime(sess.DateStart)
|
||||||
|
end, _ := parseContextTime(sess.DateEnd)
|
||||||
|
if end.IsZero() && !start.IsZero() {
|
||||||
|
end = start.Add(3 * time.Hour)
|
||||||
|
}
|
||||||
|
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
|
||||||
|
if countErr != nil {
|
||||||
|
return WeekendContext{}, countErr
|
||||||
|
}
|
||||||
|
archived := evidence.Final && liveMatches(evidence, m, sess)
|
||||||
|
candidates = append(candidates, contextCandidate{
|
||||||
|
meeting: m, session: sess, start: start, end: end, counts: counts,
|
||||||
|
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
|
||||||
|
|
||||||
|
champMeetings := championshipMeetings(meetings, byMeeting)
|
||||||
|
out.TotalChampionshipRounds = len(champMeetings)
|
||||||
|
|
||||||
|
var active *contextCandidate
|
||||||
|
if evidence.Active {
|
||||||
|
for i := range candidates {
|
||||||
|
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
|
||||||
|
active = &candidates[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active == nil {
|
||||||
|
active = syntheticLiveCandidate(evidence, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous, next, defaultAnalysis, pending *contextCandidate
|
||||||
|
for i := range candidates {
|
||||||
|
c := &candidates[i]
|
||||||
|
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
||||||
|
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
|
||||||
|
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
|
||||||
|
previous = c
|
||||||
|
}
|
||||||
|
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
|
||||||
|
defaultAnalysis = c
|
||||||
|
}
|
||||||
|
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
||||||
|
next = c
|
||||||
|
}
|
||||||
|
if !isActive && !c.complete && !c.start.IsZero() && !c.start.After(now) &&
|
||||||
|
(c.end.IsZero() || now.Before(c.end)) && (pending == nil || c.start.After(pending.start)) {
|
||||||
|
pending = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if previous != nil {
|
||||||
|
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
|
||||||
|
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if defaultAnalysis != nil {
|
||||||
|
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
out.NextSession = sessionRef(*next, evidence, now)
|
||||||
|
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if active != nil {
|
||||||
|
out.ActiveSession = sessionRef(*active, evidence, now)
|
||||||
|
out.FocusMeeting = out.ActiveSession.Meeting
|
||||||
|
out.TemporalState = TemporalSessionLive
|
||||||
|
} else {
|
||||||
|
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
|
||||||
|
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
|
||||||
|
}
|
||||||
|
if out.FocusMeeting != nil {
|
||||||
|
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
||||||
|
}
|
||||||
|
applyRaceHubDefault(&out, active, defaultAnalysis, next, pending, now)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRaceHubDefault is deliberately distinct from TemporalPreSession. Other
|
||||||
|
// weekend surfaces begin preparation 48 hours ahead; Race Hub remains an
|
||||||
|
// analysis destination until the one-hour handoff before the next session.
|
||||||
|
func applyRaceHubDefault(out *WeekendContext, active, analysis, next, pending *contextCandidate, now time.Time) {
|
||||||
|
if active != nil {
|
||||||
|
out.RaceHubDefaultSession = out.ActiveSession
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
handoff := next.start.Add(-time.Hour)
|
||||||
|
if now.Before(handoff) {
|
||||||
|
out.RaceHubRefreshAt = handoff.Format(time.RFC3339)
|
||||||
|
} else if now.Before(next.start) {
|
||||||
|
out.RaceHubDefaultSession = out.NextSession
|
||||||
|
out.RaceHubPreSession = true
|
||||||
|
out.RaceHubRefreshAt = next.start.Format(time.RFC3339)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending != nil {
|
||||||
|
out.RaceHubDefaultSession = sessionRef(*pending, LiveEvidence{}, now)
|
||||||
|
out.RaceHubPreSession = true
|
||||||
|
out.RaceHubRefreshAt = now.Add(raceHubPendingPollInterval).Format(time.RFC3339)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if analysis != nil {
|
||||||
|
out.RaceHubDefaultSession = out.DefaultAnalysisSession
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentLocalSeason(years []int, current int) int {
|
||||||
|
for _, year := range years {
|
||||||
|
if year == current {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, year := range years {
|
||||||
|
if year < current {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return years[len(years)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContextTime(value string) (time.Time, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed.UTC(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
|
||||||
|
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateTime(c contextCandidate) time.Time {
|
||||||
|
if !c.end.IsZero() {
|
||||||
|
return c.end
|
||||||
|
}
|
||||||
|
return c.start
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
|
||||||
|
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
|
||||||
|
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
|
||||||
|
if normalizeIdentity(e.SessionName) == "" {
|
||||||
|
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
|
||||||
|
}
|
||||||
|
return meetingMatch && sessionMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedEqual(a, b string) bool {
|
||||||
|
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
|
||||||
|
}
|
||||||
|
func normalizedContains(a, b string) bool {
|
||||||
|
a, b = normalizeIdentity(a), normalizeIdentity(b)
|
||||||
|
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
|
||||||
|
}
|
||||||
|
func normalizeIdentity(v string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
return r + ('a' - 'A')
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
|
||||||
|
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
|
||||||
|
var first, last time.Time
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if sess.IsCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, ok := parseContextTime(sess.DateStart)
|
||||||
|
if ok && (first.IsZero() || start.Before(first)) {
|
||||||
|
first = start
|
||||||
|
}
|
||||||
|
end, ok := parseContextTime(sess.DateEnd)
|
||||||
|
if !ok {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
if !end.IsZero() && (last.IsZero() || end.After(last)) {
|
||||||
|
last = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !first.IsZero() {
|
||||||
|
m.DateStart = first.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if !last.IsZero() {
|
||||||
|
m.DateEnd = last.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
if meeting.MeetingKey == key {
|
||||||
|
model := meetingToModel(meeting)
|
||||||
|
return &model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
||||||
|
session := sessionToModel(c.session)
|
||||||
|
meeting := meetingToModel(c.meeting)
|
||||||
|
// Schedule and analysis are domain-store facts. Without an ingestion
|
||||||
|
// timestamp the resolver cannot honestly call them network-fresh, so local
|
||||||
|
// is the baseline freshness vocabulary exposed to clients.
|
||||||
|
availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}}
|
||||||
|
availability.LiveTransport = "unknown"
|
||||||
|
if c.session.SessionKey == 0 {
|
||||||
|
availability.Schedule = "unavailable"
|
||||||
|
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
|
||||||
|
}
|
||||||
|
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
|
||||||
|
availability.Source = "mixed"
|
||||||
|
availability.LiveTransport = "connected"
|
||||||
|
availability.LiveSession = "active"
|
||||||
|
availability.Freshness = "live"
|
||||||
|
if !evidence.ObservedAt.IsZero() {
|
||||||
|
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.archived {
|
||||||
|
availability.Source = "mixed"
|
||||||
|
availability.Archive = "available"
|
||||||
|
availability.Freshness = "archive"
|
||||||
|
if !evidence.ObservedAt.IsZero() {
|
||||||
|
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
|
||||||
|
availability.LocalAnalysis = "complete"
|
||||||
|
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
|
||||||
|
availability.LocalAnalysis = "partial"
|
||||||
|
} else if !c.start.IsZero() && c.start.After(now) {
|
||||||
|
availability.LocalAnalysis = "not_applicable"
|
||||||
|
} else {
|
||||||
|
availability.LocalAnalysis = "pending"
|
||||||
|
}
|
||||||
|
if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
|
||||||
|
availability.Freshness = "partial"
|
||||||
|
}
|
||||||
|
if c.session.SessionKey == 0 && availability.LiveSession == "active" {
|
||||||
|
availability.Source = "fia"
|
||||||
|
}
|
||||||
|
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
|
||||||
|
if next != nil {
|
||||||
|
return meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if previous != nil {
|
||||||
|
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
|
||||||
|
var latestStarted *contextCandidate
|
||||||
|
for i := range candidates {
|
||||||
|
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
|
||||||
|
latestStarted = &candidates[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Once a new meeting enters its preparation window, an ingest gap from an
|
||||||
|
// older meeting must not keep the product stuck in settling.
|
||||||
|
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
|
||||||
|
if now.Before(latestStarted.end) {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
return TemporalSessionSettling
|
||||||
|
}
|
||||||
|
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
|
||||||
|
return TemporalBetweenSessions
|
||||||
|
}
|
||||||
|
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
|
||||||
|
return TemporalPostWeekend
|
||||||
|
}
|
||||||
|
if next != nil && next.start.Sub(now) <= preSessionWindow {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
if next == nil && len(championship) > 0 && !scheduleUnknown {
|
||||||
|
return TemporalSeasonComplete
|
||||||
|
}
|
||||||
|
return TemporalBetweenWeekends
|
||||||
|
}
|
||||||
|
|
||||||
|
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
for _, session := range sessions[meeting.MeetingKey] {
|
||||||
|
if !session.IsCancelled && isChampionshipRace(session) {
|
||||||
|
if _, ok := parseContextTime(session.DateStart); !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
|
||||||
|
latest := previous.start
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
|
||||||
|
var out []store.Meeting
|
||||||
|
for _, m := range meetings {
|
||||||
|
if m.IsCancelled || isTestMeeting(m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sess := range sessions[m.MeetingKey] {
|
||||||
|
if !sess.IsCancelled && isChampionshipRace(sess) {
|
||||||
|
out = append(out, m)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, _ := parseContextTime(out[i].DateStart)
|
||||||
|
b, _ := parseContextTime(out[j].DateStart)
|
||||||
|
return a.Before(b)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTestMeeting(m store.Meeting) bool {
|
||||||
|
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
|
||||||
|
return strings.Contains(n, "test")
|
||||||
|
}
|
||||||
|
func isChampionshipRace(s store.Session) bool {
|
||||||
|
n := strings.ToLower(s.SessionName)
|
||||||
|
t := strings.ToLower(s.SessionType)
|
||||||
|
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
|
||||||
|
}
|
||||||
|
func championshipRound(meetings []store.Meeting, key int) int {
|
||||||
|
for i, m := range meetings {
|
||||||
|
if m.MeetingKey == key {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
486
internal/query/context_test.go
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextService(t *testing.T, now time.Time) *Service {
|
||||||
|
t.Helper()
|
||||||
|
base := openTestService(t)
|
||||||
|
return NewServiceWithClock(base.store, func() time.Time { return now })
|
||||||
|
}
|
||||||
|
|
||||||
|
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextTemporalStates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
seed func(*testing.T, *Service)
|
||||||
|
evidence LiveEvidence
|
||||||
|
want TemporalState
|
||||||
|
}{
|
||||||
|
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
|
||||||
|
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 11, 1)
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalBetweenWeekends},
|
||||||
|
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalPreSession},
|
||||||
|
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
|
||||||
|
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalSessionSettling},
|
||||||
|
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 21, 2)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalBetweenSessions},
|
||||||
|
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 22, 2)
|
||||||
|
}, want: TemporalPostWeekend},
|
||||||
|
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 22, 2)
|
||||||
|
}, want: TemporalSeasonComplete},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, tt.now)
|
||||||
|
svc := contextService(t, now)
|
||||||
|
if tt.seed != nil {
|
||||||
|
tt.seed(t, svc)
|
||||||
|
}
|
||||||
|
got, err := svc.ResolveWeekendContext(tt.evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != tt.want {
|
||||||
|
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextTruthRules(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
|
||||||
|
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 32, 3)
|
||||||
|
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
|
||||||
|
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
|
||||||
|
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
|
||||||
|
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
|
||||||
|
t.Fatalf("next = %+v, want 40", got.NextSession)
|
||||||
|
}
|
||||||
|
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
|
||||||
|
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatalf("passed schedule was treated complete: %+v", got)
|
||||||
|
}
|
||||||
|
archive, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
|
||||||
|
t.Fatalf("final archive not used: %+v", archive)
|
||||||
|
}
|
||||||
|
if archive.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatal("archive without local analysis must not become default analysis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
// Results without laps/stints/positions are meaningful but incomplete local
|
||||||
|
// analysis, so they must not be labelled universally fresh.
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
local, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
|
||||||
|
t.Fatalf("partial local availability = %+v", got)
|
||||||
|
}
|
||||||
|
if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
|
||||||
|
t.Fatalf("future local availability = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
|
||||||
|
t.Fatalf("FIA + local availability = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
|
||||||
|
t.Fatalf("FIA archive + local availability = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
|
||||||
|
t.Fatalf("synthetic FIA availability = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatalf("future analysis selected: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the v0.4.0 release blocker (#90). Production held a run
|
||||||
|
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
|
||||||
|
// Analysis must resolve to the race that actually happened, even when the
|
||||||
|
// later meeting carries preloaded rows.
|
||||||
|
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11326, 1289)
|
||||||
|
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11334, 1290)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil {
|
||||||
|
t.Fatal("no analysis session resolved")
|
||||||
|
}
|
||||||
|
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
|
||||||
|
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
|
||||||
|
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
|
||||||
|
t.Fatalf("next session = %+v, want 11334", got.NextSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
|
||||||
|
t.Fatalf("sprint handoff = %+v", got)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 1 {
|
||||||
|
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
|
||||||
|
t.Run("pre-session window is inclusive", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPreSession {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("scheduled end enters settling", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalSessionSettling {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("post-weekend window is inclusive", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 12, 1)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPostWeekend {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
|
||||||
|
t.Fatalf("partial schedule next = %+v", got.NextSession)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
|
||||||
|
t.Fatalf("partial display range = %+v", got.FocusMeeting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 10, 1)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||||
|
t.Fatalf("active = %+v", got.ActiveSession)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
|
||||||
|
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
|
||||||
|
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPreSession {
|
||||||
|
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
|
||||||
|
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "", "", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalBetweenWeekends {
|
||||||
|
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 1 {
|
||||||
|
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextRaceHubDefault(t *testing.T) {
|
||||||
|
seed := func(t *testing.T, svc *Service) {
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("keeps completed analysis before handoff", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T07:59:59Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 11 || got.RaceHubPreSession {
|
||||||
|
t.Fatalf("race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
if got.RaceHubRefreshAt != "2026-07-17T08:00:00Z" {
|
||||||
|
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hands off exactly one hour before first session", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
|
||||||
|
t.Fatalf("race hub handoff = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
if got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" {
|
||||||
|
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps the scheduled session pending after its start without live evidence", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-17T09:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || !got.RaceHubPreSession {
|
||||||
|
t.Fatalf("scheduled race hub default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
if got.RaceHubRefreshAt != "2026-07-17T09:00:15Z" {
|
||||||
|
t.Fatalf("refresh = %q", got.RaceHubRefreshAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("active live session wins", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-17T08:30:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
seed(t, svc)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 || got.RaceHubPreSession {
|
||||||
|
t.Fatalf("live default = %+v, pre-session = %t", got.RaceHubDefaultSession, got.RaceHubPreSession)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not select an empty future session before handoff", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.RaceHubDefaultSession != nil || got.RaceHubPreSession {
|
||||||
|
t.Fatalf("unexpected empty future default: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||