Compare commits
56 Commits
feat/issue
...
3a377d0bc4
| 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 | ||
|
|
3cc6157337 |
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.
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import type {
|
|||||||
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> {
|
||||||
@@ -81,6 +83,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
|
|||||||
return data as TrackOutline
|
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) {
|
||||||
@@ -98,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())
|
||||||
|
|||||||
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,8 +21,16 @@ 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
|
||||||
|
|||||||
@@ -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,16 +299,16 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
|
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
|
||||||
if (!samples || samples.length === 0) return null
|
if (!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
|
||||||
for (let i = 0; i < samples.length - 1; i++) {
|
for (let i = 0; i < samples.length - 1; i++) {
|
||||||
if (samples[i].t <= t && samples[i+1].t >= t) {
|
if (samples[i].t <= t && samples[i + 1].t >= t) {
|
||||||
const dt = samples[i+1].t - samples[i].t
|
const dt = samples[i + 1].t - samples[i].t
|
||||||
if (dt === 0) return samples[i].pos
|
if (dt === 0) return samples[i].pos
|
||||||
const frac = (t - samples[i].t) / dt
|
const frac = (t - samples[i].t) / dt
|
||||||
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
|
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -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 ? (
|
{hasChartData && chartTiming && chapters.length > 0 && (
|
||||||
chartContent
|
<ChapterStrip
|
||||||
) : (
|
chapters={chapters}
|
||||||
<div className="analysis-notice">
|
scrubTime={scrubTime}
|
||||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
tMin={chartTiming.tMin}
|
||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
tRange={chartTiming.tRange}
|
||||||
</div>
|
tourActive={chapterTourActive}
|
||||||
|
tourChapterIndex={tourChapterIndex}
|
||||||
|
selectedChapterIndex={selectedChapterIndex}
|
||||||
|
onChapterClick={jumpToChapter}
|
||||||
|
onTourToggle={toggleChapterTour}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
||||||
|
<div className="rs-replay-main">
|
||||||
|
{hasChartData ? (
|
||||||
|
chartContent
|
||||||
|
) : (
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={LineChart}
|
||||||
|
title="Lap-by-lap positions not available"
|
||||||
|
hint={
|
||||||
|
<>
|
||||||
|
This session does not have ingested position samples in{' '}
|
||||||
|
<code>/api/v1/race-hub</code>.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
testId="race-story-no-positions"
|
||||||
|
className="race-story-empty-card"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showMapPanel && (
|
||||||
|
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
||||||
|
<ReplayTrackMap
|
||||||
|
outline={outlineQuery.data}
|
||||||
|
replay={replayQuery.data}
|
||||||
|
tMs={replayTMs}
|
||||||
|
drivers={drivers}
|
||||||
|
results={results}
|
||||||
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||||
|
error={outlineQuery.isError || replayQuery.isError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{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 && (
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
<span data-testid="live-lap-counter">
|
||||||
</span>
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{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
|
||||||
|
})
|
||||||
|
}
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|||||||
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])
|
setSelectedItem(null)
|
||||||
|
}, [])
|
||||||
const handlePageChange = useCallback(
|
|
||||||
(nextPage: number) => {
|
|
||||||
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
|
||||||
setSelectedItem(null)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
},
|
|
||||||
[pageCount],
|
|
||||||
)
|
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const 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="digest-sticky" data-testid="digest-sticky-header">
|
||||||
|
<div className="digest-sticky-head">
|
||||||
|
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
||||||
|
<span className="digest-sticky-count">
|
||||||
|
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(sinceLastTags.length > 0 || activeTag) && (
|
||||||
|
<div className="digest-sticky-tags">
|
||||||
|
{sinceLastTags.map((tag) => (
|
||||||
|
<TagChip
|
||||||
|
key={tag.key}
|
||||||
|
tag={tag}
|
||||||
|
active={activeTag === tag.key}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeTag && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="digest-filter-clear"
|
||||||
|
onClick={() => setActiveTag(null)}
|
||||||
|
data-testid="digest-filter-clear"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
<div className="bp-empty">
|
<div className="bp-empty">
|
||||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
{activeTag ? ' Try clearing the tag filter.' : ''}
|
||||||
|
{!activeTag && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{showPagination && (
|
{windowSections.map(({ window, items }) => (
|
||||||
<div className="bp-pagination bp-pagination-top">
|
<section
|
||||||
<span className="bp-pagination-meta mono">
|
key={window.meeting_key}
|
||||||
{pageStart + 1}-{pageEnd} of {filtered.length}
|
className="digest-section"
|
||||||
</span>
|
data-testid={`digest-window-${window.meeting_key}`}
|
||||||
<div className="bp-pagination-actions">
|
>
|
||||||
<button
|
<div className="digest-section-head">
|
||||||
className="bp-page-btn"
|
<h3 className="digest-section-title">{window.meeting_name}</h3>
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
<span className="digest-section-count">{items.length}</span>
|
||||||
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>
|
||||||
</div>
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
)}
|
{items.map((item) => {
|
||||||
|
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
|
||||||
|
return (
|
||||||
|
<BriefingCard
|
||||||
|
key={item.url}
|
||||||
|
item={tagged}
|
||||||
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
{recentTagged.length > 0 && (
|
||||||
{pagedItems.map((item) => (
|
<section className="digest-section digest-recent-section" data-testid="digest-recent">
|
||||||
<BriefingCard
|
<div className="digest-section-head">
|
||||||
key={item.url}
|
<h3 className="digest-section-title">Recent</h3>
|
||||||
item={item}
|
<span className="digest-section-count">{recentTagged.length}</span>
|
||||||
isActive={selectedItem?.url === item.url}
|
</div>
|
||||||
onSelect={handleSelect}
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
/>
|
{recentTagged.map((item) => (
|
||||||
))}
|
<BriefingCard
|
||||||
</div>
|
key={item.url}
|
||||||
|
item={item}
|
||||||
{showPagination && (
|
isActive={selectedItem?.url === item.url}
|
||||||
<div className="bp-pagination bp-pagination-bottom">
|
activeTag={activeTag}
|
||||||
<button
|
onSelect={handleSelect}
|
||||||
className="bp-page-btn"
|
onTagClick={handleTagClick}
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
/>
|
||||||
disabled={safePage === 1}
|
))}
|
||||||
>
|
</div>
|
||||||
Previous
|
</section>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ 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'
|
||||||
@@ -214,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' : ''}`}
|
||||||
@@ -243,11 +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}
|
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>
|
||||||
)
|
)
|
||||||
@@ -273,15 +286,19 @@ function DriversView({
|
|||||||
leaderPoints,
|
leaderPoints,
|
||||||
titleMath,
|
titleMath,
|
||||||
roundsLeft,
|
roundsLeft,
|
||||||
|
drivers,
|
||||||
season,
|
season,
|
||||||
}: {
|
}: {
|
||||||
enriched: EnrichedDriver[]
|
enriched: EnrichedDriver[]
|
||||||
leaderPoints: number
|
leaderPoints: number
|
||||||
titleMath: string
|
titleMath: string
|
||||||
roundsLeft: number
|
roundsLeft: number
|
||||||
|
drivers: ChampHubDriver[]
|
||||||
season: number
|
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">
|
||||||
@@ -340,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25],
|
form: [25],
|
||||||
cumulative: [200],
|
cumulative: [200],
|
||||||
|
round_positions: [1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
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, {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25],
|
form: [25],
|
||||||
cumulative: [200],
|
cumulative: [200],
|
||||||
|
round_positions: [1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
@@ -97,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
|
||||||
@@ -216,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
|
||||||
@@ -360,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
|
||||||
@@ -373,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +43,18 @@ 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 {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
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 len(driverStints) > 0 {
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package query
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
@@ -12,11 +13,20 @@ import (
|
|||||||
// Service assembles store-backed read models.
|
// Service assembles store-backed read models.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a query service over a domain store.
|
// NewService creates a query service over a domain store.
|
||||||
func NewService(st *store.Store) *Service {
|
func NewService(st *store.Store) *Service {
|
||||||
return &Service{store: st}
|
return NewServiceWithClock(st, time.Now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceWithClock creates a query service with an injected clock.
|
||||||
|
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
return &Service{store: st, now: now}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichedResult is a session result with driver identity fields.
|
// EnrichedResult is a session result with driver identity fields.
|
||||||
@@ -252,10 +262,38 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||||
|
hub.Chapters = chapters.ApplyHeadlines(
|
||||||
|
hub.Chapters,
|
||||||
|
chapters.BuildDriverMap(hub.Drivers, driverIdentityInputs(hub.Results)),
|
||||||
|
hub.RaceControl,
|
||||||
|
winnerDriverNumber(hub.Results),
|
||||||
|
)
|
||||||
hub.Source = responseSource(hub.Datasets)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
return hub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
|
||||||
|
out := make([]chapters.DriverIdentityInput, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
out = append(out, chapters.DriverIdentityInput{
|
||||||
|
DriverNumber: r.DriverNumber,
|
||||||
|
NameAcronym: r.NameAcronym,
|
||||||
|
FullName: r.FullName,
|
||||||
|
TeamName: r.TeamName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func winnerDriverNumber(results []EnrichedResult) int {
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Position == 1 {
|
||||||
|
return r.DriverNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||||
total := 0
|
total := 0
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
readability "codeberg.org/readeck/go-readability/v2"
|
readability "codeberg.org/readeck/go-readability/v2"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Meeting{})
|
writeJSON(w, []models.Meeting{})
|
||||||
return
|
return
|
||||||
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(meetings) > 0 {
|
if len(meetings) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, meetings)
|
writeJSON(w, meetings)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meetings, err := s.client.GetMeetingsForYear(year)
|
client := s.client.Scoped()
|
||||||
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, meetings)
|
writeJSON(w, meetings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Session{})
|
writeJSON(w, []models.Session{})
|
||||||
return
|
return
|
||||||
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(sessions) > 0 {
|
if len(sessions) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := s.client.GetSessionsForMeeting(meetingKey)
|
client := s.client.Scoped()
|
||||||
|
sessions, err := client.GetSessionsForMeeting(meetingKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /api/v1/news ---
|
// --- /api/v1/news ---
|
||||||
|
|
||||||
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []query.NewsItem{})
|
writeJSON(w, []query.NewsItem{})
|
||||||
return
|
return
|
||||||
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Driver{})
|
writeJSON(w, []models.Driver{})
|
||||||
return
|
return
|
||||||
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
drivers, err := s.query.ListDrivers(sessionKey)
|
drivers, err := s.query.ListDrivers(sessionKey)
|
||||||
if err == nil && len(drivers) > 0 {
|
if err == nil && len(drivers) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, drivers)
|
writeJSON(w, drivers)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,11 +264,13 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drivers, err := s.client.GetDriversForSession(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
drivers, err := client.GetDriversForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, drivers)
|
writeJSON(w, drivers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []resultWithDriver{})
|
writeJSON(w, []resultWithDriver{})
|
||||||
return
|
return
|
||||||
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
results, err := s.query.ListResults(sessionKey)
|
results, err := s.query.ListResults(sessionKey)
|
||||||
if err == nil && len(results) > 0 {
|
if err == nil && len(results) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, enrichedResultsToAPI(results))
|
writeJSON(w, enrichedResultsToAPI(results))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,30 +324,42 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
results []models.SessionResult
|
results []models.SessionResult
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
resultsErr error
|
resultsErr error
|
||||||
|
driversErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }()
|
go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if resultsErr != nil {
|
if resultsErr != nil {
|
||||||
writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
driverMap := buildDriverMap(drivers)
|
driverMap := buildDriverMap(drivers)
|
||||||
|
incomplete := driversErr != nil
|
||||||
enriched := make([]resultWithDriver, 0, len(results))
|
enriched := make([]resultWithDriver, 0, len(results))
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
e := resultWithDriver{SessionResult: res}
|
e := resultWithDriver{SessionResult: res}
|
||||||
if d, ok := driverMap[res.DriverNumber]; ok {
|
if d, ok := driverMap[res.DriverNumber]; ok && hasDriverPresentation(d) {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
e.TeamName = d.TeamName
|
e.TeamName = d.TeamName
|
||||||
e.TeamColour = d.TeamColour
|
e.TeamColour = d.TeamColour
|
||||||
|
} else {
|
||||||
|
incomplete = true
|
||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
resultsFreshness := "fresh"
|
||||||
|
if len(results) == 0 {
|
||||||
|
resultsFreshness = "limited"
|
||||||
|
} else if incomplete {
|
||||||
|
resultsFreshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, resultsFreshness)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +382,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []gridWithDriver{})
|
writeJSON(w, []gridWithDriver{})
|
||||||
return
|
return
|
||||||
@@ -369,6 +398,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
grid, err := s.query.ListStartingGrid(sessionKey)
|
grid, err := s.query.ListStartingGrid(sessionKey)
|
||||||
if err == nil && len(grid) > 0 {
|
if err == nil && len(grid) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, enrichedGridToAPI(grid))
|
writeJSON(w, enrichedGridToAPI(grid))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -380,33 +410,45 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
grid []models.StartingGrid
|
grid []models.StartingGrid
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
gridErr error
|
gridErr error
|
||||||
wg sync.WaitGroup
|
driversErr error
|
||||||
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }()
|
go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if gridErr != nil {
|
if gridErr != nil {
|
||||||
writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
driverMap := buildDriverMap(drivers)
|
driverMap := buildDriverMap(drivers)
|
||||||
|
incomplete := driversErr != nil
|
||||||
enriched := make([]gridWithDriver, 0, len(grid))
|
enriched := make([]gridWithDriver, 0, len(grid))
|
||||||
for _, g := range grid {
|
for _, g := range grid {
|
||||||
e := gridWithDriver{StartingGrid: g}
|
e := gridWithDriver{StartingGrid: g}
|
||||||
if d, ok := driverMap[g.DriverNumber]; ok {
|
if d, ok := driverMap[g.DriverNumber]; ok && hasDriverPresentation(d) {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
e.TeamName = d.TeamName
|
e.TeamName = d.TeamName
|
||||||
e.TeamColour = d.TeamColour
|
e.TeamColour = d.TeamColour
|
||||||
|
} else {
|
||||||
|
incomplete = true
|
||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
gridFreshness := "fresh"
|
||||||
|
if len(grid) == 0 {
|
||||||
|
gridFreshness = "limited"
|
||||||
|
} else if incomplete {
|
||||||
|
gridFreshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, gridFreshness)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,26 +461,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := s.client.Scoped()
|
||||||
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
|
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
|
||||||
driverNumber, err := strconv.Atoi(dnStr)
|
driverNumber, err := strconv.Atoi(dnStr)
|
||||||
if err != nil || driverNumber == 0 {
|
if err != nil || driverNumber == 0 {
|
||||||
http.Error(w, "invalid driver_number", http.StatusBadRequest)
|
http.Error(w, "invalid driver_number", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber)
|
laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, laps)
|
writeJSON(w, laps)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := s.client.GetLapsForSession(sessionKey)
|
laps, err := client.GetLapsForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, laps)
|
writeJSON(w, laps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,11 +495,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
weather, err := s.client.GetWeather(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
weather, err := client.GetWeather(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, weather)
|
writeJSON(w, weather)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,11 +513,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rc, err := s.client.GetRaceControl(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
rc, err := client.GetRaceControl(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, rc)
|
writeJSON(w, rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +536,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
carData, err := s.client.GetCarData(sessionKey, driverNumber)
|
client := s.client.Scoped()
|
||||||
|
carData, err := client.GetCarData(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, carData)
|
writeJSON(w, carData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,11 +554,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
overtakes, err := s.client.GetOvertakesForSession(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, overtakes)
|
writeJSON(w, overtakes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,11 +577,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
radios, err := s.client.GetTeamRadio(sessionKey, driverNumber)
|
client := s.client.Scoped()
|
||||||
|
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, radios)
|
writeJSON(w, radios)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,36 +602,42 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
client := s.client.Scoped()
|
||||||
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(champ) == 0 {
|
if len(champ) == 0 {
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
writeJSON(w, []any{})
|
writeJSON(w, []any{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
|
drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
|
||||||
driverMap := buildDriverMapFirst(drivers)
|
driverMap := buildDriverMapFirst(drivers)
|
||||||
|
incomplete := driversErr != nil
|
||||||
|
|
||||||
enriched := make([]champDriverWithInfo, 0, len(champ))
|
enriched := make([]champDriverWithInfo, 0, len(champ))
|
||||||
for _, c := range champ {
|
for _, c := range champ {
|
||||||
e := champDriverWithInfo{ChampionshipDriver: c}
|
e := champDriverWithInfo{ChampionshipDriver: c}
|
||||||
d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap)
|
d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
|
||||||
if ok {
|
if ok && hasDriverPresentation(d) {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
e.TeamName = d.TeamName
|
e.TeamName = d.TeamName
|
||||||
e.TeamColour = d.TeamColour
|
e.TeamColour = d.TeamColour
|
||||||
|
} else {
|
||||||
|
incomplete = true
|
||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
|
func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
|
||||||
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
|
if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
|
||||||
return *d, true
|
return *d, true
|
||||||
}
|
}
|
||||||
d, ok := fallback[driverNumber]
|
d, ok := fallback[driverNumber]
|
||||||
@@ -590,11 +651,17 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
teams, err := s.client.GetTeamChampionshipForYear(year)
|
client := s.client.Scoped()
|
||||||
|
teams, err := client.GetTeamChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(teams) == 0 {
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
|
} else {
|
||||||
|
markOpenF1Response(w, client)
|
||||||
|
}
|
||||||
writeJSON(w, teams)
|
writeJSON(w, teams)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,8 +681,9 @@ type champHubDriver struct {
|
|||||||
Wins int `json:"wins"`
|
Wins int `json:"wins"`
|
||||||
Podiums int `json:"podiums"`
|
Podiums int `json:"podiums"`
|
||||||
Poles int `json:"poles"`
|
Poles int `json:"poles"`
|
||||||
Form []float64 `json:"form"` // last 5 races' points
|
Form []float64 `json:"form"` // last 5 races' points
|
||||||
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
||||||
|
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
|
||||||
TeammateWins int `json:"teammate_wins"`
|
TeammateWins int `json:"teammate_wins"`
|
||||||
TeammateLosses int `json:"teammate_losses"`
|
TeammateLosses int `json:"teammate_losses"`
|
||||||
}
|
}
|
||||||
@@ -670,8 +738,10 @@ func champHubTTL(year int, now time.Time) time.Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type champHubEntry struct {
|
type champHubEntry struct {
|
||||||
resp champHubResponse
|
resp champHubResponse
|
||||||
expires time.Time
|
source string
|
||||||
|
freshness string
|
||||||
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// champHubCache is an in-memory cache of aggregated hub responses keyed by
|
// champHubCache is an in-memory cache of aggregated hub responses keyed by
|
||||||
@@ -692,12 +762,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
|
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
|
||||||
|
c.putWithMetadata(year, resp, "local", "local", now, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e, ok := c.entries[year]
|
||||||
|
if !ok || now.After(e.expires) {
|
||||||
|
return champHubResponse{}, "", "", false
|
||||||
|
}
|
||||||
|
return e.resp, e.source, e.freshness, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.entries == nil {
|
if c.entries == nil {
|
||||||
c.entries = map[int]champHubEntry{}
|
c.entries = map[int]champHubEntry{}
|
||||||
}
|
}
|
||||||
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)}
|
c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
|
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
|
||||||
@@ -739,13 +823,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
mode := parseSourceMode(r)
|
mode := parseSourceMode(r)
|
||||||
|
|
||||||
if mode != sourceLocal {
|
|
||||||
if resp, ok := s.hubCache.get(year, time.Now()); ok {
|
|
||||||
writeJSON(w, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if mode == sourceLocal || mode == sourceAuto {
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
resp, ok, err := s.localChampionshipHub(year)
|
resp, ok, err := s.localChampionshipHub(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -753,21 +830,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now()))
|
s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now()))
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mode == sourceLocal {
|
if mode == sourceLocal {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.openF1ChampionshipHub(year)
|
// At this point an auto request has no usable domain aggregate and an
|
||||||
if err != nil {
|
// explicit OpenF1 request must not be satisfied by a local cache entry.
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
|
||||||
|
markDataResponse(w, source, freshness)
|
||||||
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := s.client.Scoped()
|
||||||
|
resp, incomplete, err := s.openF1ChampionshipHub(client, year)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
|
|||||||
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
|
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, false, err
|
||||||
}
|
}
|
||||||
if len(champ) == 0 {
|
if len(champ) == 0 {
|
||||||
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil
|
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, true, nil
|
||||||
}
|
}
|
||||||
teams, _ := s.client.GetTeamChampionshipForYear(year)
|
teams, teamsErr := client.GetTeamChampionshipForYear(year)
|
||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
driversIncomplete := false
|
||||||
|
if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
|
} else {
|
||||||
|
driversIncomplete = true
|
||||||
|
}
|
||||||
|
for _, standing := range champ {
|
||||||
|
if !hasDriverPresentation(driverInfo[standing.DriverNumber]) {
|
||||||
|
driversIncomplete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
races, incomplete, err := s.fetchSeasonRaces(year)
|
races, incomplete, err := fetchSeasonRaces(client, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, false, err
|
||||||
}
|
}
|
||||||
|
incomplete = incomplete || teamsErr != nil || driversIncomplete
|
||||||
|
|
||||||
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
||||||
ttl := champHubTTL(year, time.Now())
|
ttl := champHubTTL(year, time.Now())
|
||||||
@@ -824,16 +923,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
|||||||
// so a partial view of the season doesn't stick around for the full TTL.
|
// so a partial view of the season doesn't stick around for the full TTL.
|
||||||
ttl = champHubIncompleteTTL
|
ttl = champHubIncompleteTTL
|
||||||
}
|
}
|
||||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
freshness := "fresh"
|
||||||
return resp, nil
|
if client.LastResponseWasStale() {
|
||||||
|
freshness = "stale"
|
||||||
|
} else if incomplete {
|
||||||
|
freshness = "partial"
|
||||||
|
}
|
||||||
|
s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl)
|
||||||
|
return resp, incomplete, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
||||||
// with its race results and starting grid fetched from OpenF1. incomplete
|
// with its race results and starting grid fetched from OpenF1. incomplete
|
||||||
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
||||||
// partial view of the season for long.
|
// partial view of the season for long.
|
||||||
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
|
func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
|
||||||
meetings, err := s.client.GetMeetingsForYear(year)
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -841,7 +946,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
|
|
||||||
var failed atomic.Bool
|
var failed atomic.Bool
|
||||||
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||||
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
|
sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey))
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
return meetingRace{}, false
|
return meetingRace{}, false
|
||||||
@@ -854,10 +959,17 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if raceKey == 0 {
|
if raceKey == 0 {
|
||||||
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
|
if isKnownNonChampionshipMeeting(m, sessions) {
|
||||||
|
return meetingRace{}, false
|
||||||
|
}
|
||||||
|
failed.Store(true)
|
||||||
|
// The meeting list does not identify non-championship events. Skipping
|
||||||
|
// a meeting without a Race may be expected (testing), but the aggregate
|
||||||
|
// is not proven complete and must be labelled partial.
|
||||||
|
return meetingRace{}, false
|
||||||
}
|
}
|
||||||
results, rerr := s.client.GetSessionResult(raceKey)
|
results, rerr := client.GetSessionResult(raceKey)
|
||||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
grid, gerr := client.GetStartingGrid(raceKey)
|
||||||
if rerr != nil || gerr != nil {
|
if rerr != nil || gerr != nil {
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
}
|
}
|
||||||
@@ -866,6 +978,29 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
return races, failed.Load(), nil
|
return races, failed.Load(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
|
||||||
|
if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, session := range sessions {
|
||||||
|
if hasTestingToken(session.SessionName + " " + session.SessionType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTestingToken(value string) bool {
|
||||||
|
for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||||
|
}) {
|
||||||
|
if token == "test" || token == "tests" || token == "testing" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||||
// unit-tested with synthetic data. races must be ordered ascending by date and
|
// unit-tested with synthetic data. races must be ordered ascending by date and
|
||||||
// contain only GP meetings (those with a Race session).
|
// contain only GP meetings (those with a Race session).
|
||||||
@@ -995,6 +1130,10 @@ func aggregateChampionshipHub(
|
|||||||
if len(form) > 5 {
|
if len(form) > 5 {
|
||||||
form = form[len(form)-5:]
|
form = form[len(form)-5:]
|
||||||
}
|
}
|
||||||
|
roundPositions := make([]int, completed)
|
||||||
|
for round := 1; round <= completed; round++ {
|
||||||
|
roundPositions[round-1] = a.finishByRound[round]
|
||||||
|
}
|
||||||
info := driverInfo[c.DriverNumber]
|
info := driverInfo[c.DriverNumber]
|
||||||
drivers = append(drivers, champHubDriver{
|
drivers = append(drivers, champHubDriver{
|
||||||
DriverNumber: c.DriverNumber,
|
DriverNumber: c.DriverNumber,
|
||||||
@@ -1009,6 +1148,7 @@ func aggregateChampionshipHub(
|
|||||||
Poles: a.poles,
|
Poles: a.poles,
|
||||||
Form: form,
|
Form: form,
|
||||||
Cumulative: cumulative[c.DriverNumber],
|
Cumulative: cumulative[c.DriverNumber],
|
||||||
|
RoundPositions: roundPositions,
|
||||||
TeammateWins: twins[c.DriverNumber],
|
TeammateWins: twins[c.DriverNumber],
|
||||||
TeammateLosses: tloss[c.DriverNumber],
|
TeammateLosses: tloss[c.DriverNumber],
|
||||||
})
|
})
|
||||||
@@ -1071,6 +1211,7 @@ type trackOutlineResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
@@ -1267,22 +1408,25 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
stints []models.Stint
|
stints []models.Stint
|
||||||
pits []models.Pit
|
pits []models.Pit
|
||||||
results []models.SessionResult
|
results []models.SessionResult
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
rc []models.RaceControl
|
rc []models.RaceControl
|
||||||
stintsErr error
|
stintsErr error
|
||||||
pitsErr error
|
pitsErr error
|
||||||
resErr error
|
resErr error
|
||||||
wg sync.WaitGroup
|
driversErr error
|
||||||
|
rcErr error
|
||||||
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(5)
|
wg.Add(5)
|
||||||
go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }()
|
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }()
|
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }()
|
go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
|
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if stintsErr != nil || pitsErr != nil || resErr != nil {
|
if stintsErr != nil || pitsErr != nil || resErr != nil {
|
||||||
@@ -1293,17 +1437,21 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e == nil {
|
if e == nil {
|
||||||
e = resErr
|
e = resErr
|
||||||
}
|
}
|
||||||
writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-race sessions have no stints.
|
// Empty strategy data may mean a non-race session or a race still settling.
|
||||||
if len(stints) == 0 {
|
if len(stints) == 0 {
|
||||||
|
// Without session-type evidence, an empty primary strategy dataset is
|
||||||
|
// not enough to prove "not applicable" (it may still be settling).
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
driverMap := buildDriverMap(drivers)
|
driverMap := buildDriverMap(drivers)
|
||||||
|
incomplete := driversErr != nil || rcErr != nil
|
||||||
|
|
||||||
resultMap := make(map[int]models.SessionResult, len(results))
|
resultMap := make(map[int]models.SessionResult, len(results))
|
||||||
totalLaps := 0
|
totalLaps := 0
|
||||||
@@ -1336,6 +1484,9 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
|
stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
|
||||||
for dn := range seenDrivers {
|
for dn := range seenDrivers {
|
||||||
d := driverMap[dn]
|
d := driverMap[dn]
|
||||||
|
if !hasDriverPresentation(d) {
|
||||||
|
incomplete = true
|
||||||
|
}
|
||||||
res := resultMap[dn]
|
res := resultMap[dn]
|
||||||
|
|
||||||
sd := strategyDriver{
|
sd := strategyDriver{
|
||||||
@@ -1390,6 +1541,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return pi < pj
|
return pi < pj
|
||||||
})
|
})
|
||||||
|
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
writeJSON(w, strategyResponse{
|
writeJSON(w, strategyResponse{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
TotalLaps: totalLaps,
|
TotalLaps: totalLaps,
|
||||||
@@ -1485,21 +1637,31 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
allLaps []models.Lap
|
allLaps []models.Lap
|
||||||
stints []models.Stint
|
stints []models.Stint
|
||||||
pits []models.Pit
|
pits []models.Pit
|
||||||
rc []models.RaceControl
|
rc []models.RaceControl
|
||||||
wg sync.WaitGroup
|
lapsErr error
|
||||||
|
stintsErr error
|
||||||
|
pitsErr error
|
||||||
|
rcErr error
|
||||||
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(4)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }()
|
go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }()
|
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }()
|
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
|
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
if lapsErr != nil {
|
||||||
|
writeError(w, lapsErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
allDrivers, _ := s.client.GetDriversForSession(sessionKey)
|
allDrivers, driversErr := client.GetDriversForSession(sessionKey)
|
||||||
driverMap := buildDriverMap(allDrivers)
|
driverMap := buildDriverMap(allDrivers)
|
||||||
|
incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
|
||||||
|
|
||||||
// If no filter, default to first 3 unique driver numbers from lap data.
|
// If no filter, default to first 3 unique driver numbers from lap data.
|
||||||
if len(requestedDrivers) == 0 {
|
if len(requestedDrivers) == 0 {
|
||||||
@@ -1537,6 +1699,9 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
|
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
|
||||||
for _, dn := range requestedDrivers {
|
for _, dn := range requestedDrivers {
|
||||||
d := driverMap[dn]
|
d := driverMap[dn]
|
||||||
|
if !hasDriverPresentation(d) {
|
||||||
|
incomplete = true
|
||||||
|
}
|
||||||
cd := comparisonDriver{
|
cd := comparisonDriver{
|
||||||
DriverNumber: dn,
|
DriverNumber: dn,
|
||||||
NameAcronym: d.NameAcronym,
|
NameAcronym: d.NameAcronym,
|
||||||
@@ -1552,6 +1717,13 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
compDrivers = append(compDrivers, cd)
|
compDrivers = append(compDrivers, cd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
freshness := "fresh"
|
||||||
|
if len(allLaps) == 0 {
|
||||||
|
freshness = "limited"
|
||||||
|
} else if incomplete {
|
||||||
|
freshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, freshness)
|
||||||
writeJSON(w, lapsComparisonResponse{
|
writeJSON(w, lapsComparisonResponse{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
SCPeriods: extractSCPeriods(rc),
|
SCPeriods: extractSCPeriods(rc),
|
||||||
|
|||||||
@@ -1,13 +1,136 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func championshipTestUpstream(t *testing.T, driversOK, meetingHasRace bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
completed := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/sessions":
|
||||||
|
if r.URL.Query().Get("session_name") == "Race" {
|
||||||
|
_, _ = fmt.Fprintf(w, `[{"session_key":99,"session_name":"Race","date_end":%q}]`, completed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if meetingHasRace {
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":101,"meeting_key":1,"session_name":"Race"}]`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":100,"meeting_key":1,"session_name":"Practice 1"}]`))
|
||||||
|
}
|
||||||
|
case "/v1/championship_drivers":
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"session_key":99,"position_current":1,"points_current":25}]`))
|
||||||
|
case "/v1/championship_teams":
|
||||||
|
_, _ = w.Write([]byte(`[{"team_name":"Red Bull","position_current":1,"points_current":25}]`))
|
||||||
|
case "/v1/drivers":
|
||||||
|
if !driversOK {
|
||||||
|
http.Error(w, "identity unavailable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
|
||||||
|
case "/v1/meetings":
|
||||||
|
_, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Mystery Grand Prix"}]`))
|
||||||
|
case "/v1/session_result":
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
|
||||||
|
case "/v1/starting_grid":
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenF1ChampionshipHubIdentityFailureIsPartialAndCached(t *testing.T) {
|
||||||
|
upstream := championshipTestUpstream(t, false, true)
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
server := NewServer(client, 0, nil)
|
||||||
|
year := time.Now().Year()
|
||||||
|
|
||||||
|
_, incomplete, err := server.openF1ChampionshipHub(client.Scoped(), year)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !incomplete {
|
||||||
|
t.Fatal("missing championship driver identity was labelled complete")
|
||||||
|
}
|
||||||
|
_, source, freshness, ok := server.hubCache.getWithMetadata(year, time.Now())
|
||||||
|
if !ok || source != "openf1" || freshness != "partial" {
|
||||||
|
t.Fatalf("cached metadata = hit %v, %q/%q", ok, source, freshness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSeasonRacesMeetingWithoutRaceIsIncomplete(t *testing.T) {
|
||||||
|
upstream := championshipTestUpstream(t, true, false)
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !incomplete || len(races) != 0 {
|
||||||
|
t.Fatalf("no-Race meeting = races %d, incomplete %v", len(races), incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSeasonRacesRecognizedTestingMeetingIsNotIncomplete(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/meetings":
|
||||||
|
_, _ = w.Write([]byte(`[{"meeting_key":1253,"meeting_name":"Pre-Season Testing"}]`))
|
||||||
|
case "/v1/sessions":
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":1,"meeting_key":1253,"session_name":"Day 1","session_type":"Testing"}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if incomplete || len(races) != 0 {
|
||||||
|
t.Fatalf("recognized testing meeting = races %d, incomplete %v", len(races), incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnownNonChampionshipMeetingRequiresTestingToken(t *testing.T) {
|
||||||
|
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Pre-Season Testing"}, nil) {
|
||||||
|
t.Fatal("pre-season testing was not recognized")
|
||||||
|
}
|
||||||
|
if isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Fastest Grand Prix"}, nil) {
|
||||||
|
t.Fatal("substring inside a normal word was treated as testing")
|
||||||
|
}
|
||||||
|
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Winter Event"}, []models.Session{{SessionType: "Test"}}) {
|
||||||
|
t.Fatal("explicit Test session was not recognized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChampionshipHubSourceLocalWithoutAggregateIsLimited(t *testing.T) {
|
||||||
|
server := NewServer(nil, 0, nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleChampionshipHub(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/championship/hub?year=2026&source=local", nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != "none" || recorder.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("empty local championship = %d %q/%q body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func raceResult(num, pos int, pts float64) models.SessionResult {
|
func raceResult(num, pos int, pts float64) models.SessionResult {
|
||||||
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
|
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
|
||||||
}
|
}
|
||||||
@@ -17,20 +140,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
|
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||||
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
|
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||||
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
||||||
|
4: {DriverNumber: 4, NameAcronym: "NOR", FullName: "Lando Norris", TeamName: "McLaren", TeamColour: "ff8000"},
|
||||||
}
|
}
|
||||||
|
|
||||||
champ := []models.ChampionshipDriver{
|
champ := []models.ChampionshipDriver{
|
||||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||||
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
||||||
|
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
|
||||||
}
|
}
|
||||||
teams := []models.ChampionshipTeam{
|
teams := []models.ChampionshipTeam{
|
||||||
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
||||||
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
|
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. NOR absent.
|
||||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
|
// Round 2: VER P1(25), PER P2(18), HAM P3(15), NOR P4(12). Pole: HAM.
|
||||||
races := []meetingRace{
|
races := []meetingRace{
|
||||||
{
|
{
|
||||||
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
||||||
@@ -39,7 +164,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Meeting: models.Meeting{MeetingName: "Saudi GP"},
|
Meeting: models.Meeting{MeetingName: "Saudi GP"},
|
||||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)},
|
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15), raceResult(4, 4, 12)},
|
||||||
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
|
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
|
||||||
},
|
},
|
||||||
// Round 3: not yet run (no results) — should not count as completed.
|
// Round 3: not yet run (no results) — should not count as completed.
|
||||||
@@ -67,9 +192,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drivers are sorted by official position: VER, HAM, PER.
|
// Drivers are sorted by official position: VER, HAM, PER, NOR.
|
||||||
if len(resp.Drivers) != 3 {
|
if len(resp.Drivers) != 4 {
|
||||||
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
|
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
|
||||||
}
|
}
|
||||||
ver := resp.Drivers[0]
|
ver := resp.Drivers[0]
|
||||||
if ver.NameAcronym != "VER" || ver.Position != 1 {
|
if ver.NameAcronym != "VER" || ver.Position != 1 {
|
||||||
@@ -91,6 +216,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
|
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
|
||||||
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
|
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
|
||||||
}
|
}
|
||||||
|
if len(ver.RoundPositions) != 2 || ver.RoundPositions[0] != 1 || ver.RoundPositions[1] != 1 {
|
||||||
|
t.Errorf("VER round positions = %v, want [1 1]", ver.RoundPositions)
|
||||||
|
}
|
||||||
// VER beat teammate PER in both rounds.
|
// VER beat teammate PER in both rounds.
|
||||||
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
||||||
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
|
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
|
||||||
@@ -109,6 +237,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
if per.Poles != 0 {
|
if per.Poles != 0 {
|
||||||
t.Errorf("PER poles = %d, want 0", per.Poles)
|
t.Errorf("PER poles = %d, want 0", per.Poles)
|
||||||
}
|
}
|
||||||
|
if len(per.RoundPositions) != 2 || per.RoundPositions[0] != 3 || per.RoundPositions[1] != 2 {
|
||||||
|
t.Errorf("PER round positions = %v, want [3 2]", per.RoundPositions)
|
||||||
|
}
|
||||||
|
|
||||||
// HAM has no teammate in the data — no h2h recorded.
|
// HAM has no teammate in the data — no h2h recorded.
|
||||||
var ham champHubDriver
|
var ham champHubDriver
|
||||||
@@ -124,6 +255,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
|||||||
t.Errorf("HAM poles = %d, want 1", ham.Poles)
|
t.Errorf("HAM poles = %d, want 1", ham.Poles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOR missed round 1 — position 0 marks the absent round.
|
||||||
|
var nor champHubDriver
|
||||||
|
for _, d := range resp.Drivers {
|
||||||
|
if d.NameAcronym == "NOR" {
|
||||||
|
nor = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(nor.RoundPositions) != 2 || nor.RoundPositions[0] != 0 || nor.RoundPositions[1] != 4 {
|
||||||
|
t.Errorf("NOR round positions = %v, want [0 4]", nor.RoundPositions)
|
||||||
|
}
|
||||||
|
|
||||||
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
|
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
|
||||||
if len(resp.Teams) != 2 {
|
if len(resp.Teams) != 2 {
|
||||||
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
||||||
|
|||||||
120
internal/web/component_freshness_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func componentTestServer(t *testing.T, responses map[string]string, failures map[string]bool) *Server {
|
||||||
|
t.Helper()
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if failures[r.URL.Path] {
|
||||||
|
http.Error(w, "component unavailable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, ok := responses[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
return NewServer(client, 0, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAvailabilityHeaders(t *testing.T, recorder *httptest.ResponseRecorder, source, freshness string) {
|
||||||
|
t.Helper()
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != source || recorder.Header().Get(dataFreshnessHeader) != freshness {
|
||||||
|
t.Fatalf("response = status %d, metadata %q/%q, body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultsAndGridIdentityFailuresReportPartial(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
run func(*Server, http.ResponseWriter, *http.Request)
|
||||||
|
}{
|
||||||
|
{name: "results", path: "/v1/session_result", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleResults(w, r) }},
|
||||||
|
{name: "grid", path: "/v1/starting_grid", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleGrid(w, r) }},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{tt.path: tt.body}, map[string]bool{"/v1/drivers": true})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
tt.run(server, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/"+tt.name+"?session_key=99&source=openf1", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyOptionalComponentFailureReportsPartial(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{
|
||||||
|
"/v1/stints": `[{"driver_number":1,"stint_number":1,"lap_start":1,"lap_end":10,"compound":"MEDIUM"}]`,
|
||||||
|
"/v1/pit": `[]`,
|
||||||
|
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
|
||||||
|
"/v1/race_control": `[]`,
|
||||||
|
}, map[string]bool{"/v1/drivers": true})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyEmptyPrimaryDataReportsLimited(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{
|
||||||
|
"/v1/stints": `[]`,
|
||||||
|
"/v1/pit": `[]`,
|
||||||
|
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
|
||||||
|
"/v1/drivers": `[{"driver_number":1,"full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`,
|
||||||
|
"/v1/race_control": `[]`,
|
||||||
|
}, nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
laps string
|
||||||
|
freshness string
|
||||||
|
}{
|
||||||
|
{name: "empty primary data", laps: `[]`, freshness: "limited"},
|
||||||
|
{name: "optional components failed", laps: `[{"driver_number":1,"lap_number":1}]`, freshness: "partial"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{"/v1/laps": tt.laps}, map[string]bool{
|
||||||
|
"/v1/stints": true,
|
||||||
|
"/v1/pit": true,
|
||||||
|
"/v1/race_control": true,
|
||||||
|
"/v1/drivers": true,
|
||||||
|
})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", tt.freshness)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("primary laps failure is an error", func(t *testing.T) {
|
||||||
|
server := componentTestServer(t, nil, map[string]bool{
|
||||||
|
"/v1/laps": true,
|
||||||
|
"/v1/stints": true,
|
||||||
|
"/v1/pit": true,
|
||||||
|
"/v1/race_control": true,
|
||||||
|
})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
77
internal/web/context.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/live"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if !s.hasLocalQuery() {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
|
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := s.hub.State()
|
||||||
|
evidence := query.LiveEvidence{}
|
||||||
|
if state.IsLive && state.Data != nil {
|
||||||
|
evidence = liveEvidence(state.Data, true, false)
|
||||||
|
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
|
||||||
|
evidence = liveEvidence(state.LastSnapshot, false, true)
|
||||||
|
if state.LastSnapshotAt != nil {
|
||||||
|
evidence.ObservedAt = *state.LastSnapshotAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context, err := s.query.ResolveWeekendContext(evidence)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if focus := focusedContextSession(context); focus != nil {
|
||||||
|
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
|
||||||
|
} else {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
|
}
|
||||||
|
writeJSON(w, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusedContextSession selects the session whose state the Weekend shell is
|
||||||
|
// presenting. An older terminal/default session must never override an
|
||||||
|
// upcoming focus session's metadata.
|
||||||
|
func focusedContextSession(context query.WeekendContext) *query.ContextSession {
|
||||||
|
if context.ActiveSession != nil {
|
||||||
|
return context.ActiveSession
|
||||||
|
}
|
||||||
|
if context.FocusMeeting == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
focusKey := context.FocusMeeting.MeetingKey
|
||||||
|
for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
|
||||||
|
if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
|
||||||
|
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalSessionStatus(status string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, status))
|
||||||
|
switch normalized {
|
||||||
|
case "finished", "finalised", "finalized", "ended", "aborted":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
196
internal/web/context_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/live"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openContextStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContextHandler(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
|
||||||
|
s := NewServer(nil, 0, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != query.TemporalNoSeason {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
|
||||||
|
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
|
||||||
|
handler, err := s.routes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||||
|
t.Fatalf("live context = %+v", got)
|
||||||
|
}
|
||||||
|
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
|
||||||
|
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
|
||||||
|
}
|
||||||
|
if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
|
||||||
|
t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
|
||||||
|
t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
|
||||||
|
t.Fatalf("archive context = %+v", got)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
|
||||||
|
t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
|
||||||
|
t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatal("archive-only session must not become local default analysis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
|
||||||
|
t.Fatalf("focus context = %+v", got)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
|
||||||
|
t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
|
||||||
|
t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalSessionStatus(t *testing.T) {
|
||||||
|
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
|
||||||
|
if !terminalSessionStatus(status) {
|
||||||
|
t.Errorf("%q should be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
|
||||||
|
if terminalSessionStatus(status) {
|
||||||
|
t.Errorf("%q should not be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerSerializesRaceHubRefreshDeadline(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
if err := st.UpsertSessionResult(store.SessionResult{SessionKey: 11, MeetingKey: 1, DriverNumber: 1, Position: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-19T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-17T09:00:00Z", DateEnd: "2026-07-17T10:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time {
|
||||||
|
return time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)
|
||||||
|
})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.RaceHubPreSession || got.RaceHubRefreshAt != "2026-07-17T09:00:00Z" || got.RaceHubDefaultSession == nil || got.RaceHubDefaultSession.Session.SessionKey != 21 {
|
||||||
|
t.Fatalf("race hub context = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- /api/v1/driver/summary ---
|
// --- /api/v1/driver/summary ---
|
||||||
// Per-driver season summary: championship standing plus per-round race results,
|
// Per-driver season summary: championship standing plus per-round race results.
|
||||||
// aggregated server-side from the same sources as the championship hub. Caching
|
// Current-season identity/results are local-first from the domain DB. Optional
|
||||||
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
|
// OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
|
||||||
|
// the profile when remote data is slow or unavailable.
|
||||||
|
|
||||||
|
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
|
||||||
|
// call never blocks a local-first profile response. Overridable in tests.
|
||||||
|
var driverEnrichmentTimeout = 2 * time.Second
|
||||||
|
|
||||||
type driverSummaryRound struct {
|
type driverSummaryRound struct {
|
||||||
MeetingKey int `json:"meeting_key"`
|
MeetingKey int `json:"meeting_key"`
|
||||||
@@ -49,6 +56,11 @@ type driverSummaryResponse struct {
|
|||||||
Cumulative []float64 `json:"cumulative"`
|
Cumulative []float64 `json:"cumulative"`
|
||||||
RoundLabels []string `json:"round_labels"`
|
RoundLabels []string `json:"round_labels"`
|
||||||
Rounds []driverSummaryRound `json:"rounds"`
|
Rounds []driverSummaryRound `json:"rounds"`
|
||||||
|
// Source is "local" when served from the domain DB, else "openf1".
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
// Enrichment is "full" when optional remote identity landed, "limited"
|
||||||
|
// when it timed out/failed, or "none" when no enrichment was attempted.
|
||||||
|
Enrichment string `json:"enrichment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -61,12 +73,102 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
|
mode := parseSourceMode(r)
|
||||||
|
// Driver summary is local-first for current-season identity/results. When the
|
||||||
|
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
|
||||||
|
// package default of openf1-only.
|
||||||
|
if r.URL.Query().Get("source") == "" {
|
||||||
|
mode = sourceAuto
|
||||||
|
}
|
||||||
|
client := s.client.Scoped()
|
||||||
|
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
|
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
||||||
|
if lerr != nil {
|
||||||
|
writeError(w, lerr, http.StatusInternalServerError, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if mode != sourceLocal {
|
||||||
|
tryEnrichDriverSummary(r.Context(), client, &resp, sessionKey)
|
||||||
|
}
|
||||||
|
switch resp.Enrichment {
|
||||||
|
case "full":
|
||||||
|
markMixedResponse(w, client, false)
|
||||||
|
case "limited":
|
||||||
|
markDataResponse(w, "local", "limited")
|
||||||
|
default:
|
||||||
|
markLocalResponse(w, false)
|
||||||
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == sourceLocal {
|
||||||
|
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if resp == nil {
|
||||||
|
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
|
||||||
|
if !s.hasLocalQuery() {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
inputs, err := s.query.GetChampionshipInputs(year)
|
||||||
|
if err != nil {
|
||||||
|
return driverSummaryResponse{}, 0, false, err
|
||||||
|
}
|
||||||
|
if len(inputs.Champ) == 0 {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
races := make([]meetingRace, 0, len(inputs.Races))
|
||||||
|
for _, race := range inputs.Races {
|
||||||
|
races = append(races, meetingRace{
|
||||||
|
Meeting: race.Meeting,
|
||||||
|
RaceSessionKey: race.RaceSessionKey,
|
||||||
|
Results: race.Results,
|
||||||
|
Grid: race.Grid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
|
||||||
|
if !ok {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
resp.Source = "local"
|
||||||
|
resp.Enrichment = "none"
|
||||||
|
|
||||||
|
sessionKey := 0
|
||||||
|
for _, c := range inputs.Champ {
|
||||||
|
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
|
||||||
|
sessionKey = c.SessionKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if sessionKey == 0 && c.SessionKey > 0 {
|
||||||
|
sessionKey = c.SessionKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, sessionKey, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
|
||||||
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
var entry *models.ChampionshipDriver
|
var entry *models.ChampionshipDriver
|
||||||
for i := range champ {
|
for i := range champ {
|
||||||
if champ[i].DriverNumber == driverNumber {
|
if champ[i].DriverNumber == driverNumber {
|
||||||
@@ -75,30 +177,83 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
return nil, false, nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
sessionKey := champ[0].SessionKey
|
||||||
|
if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
}
|
}
|
||||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
|
||||||
driverInfo[driverNumber] = d
|
if directErr == nil && d != nil {
|
||||||
|
driverInfo[driverNumber] = *d
|
||||||
|
} else if fallback, ok := driverInfo[driverNumber]; ok {
|
||||||
|
driverInfo[driverNumber] = fallback
|
||||||
}
|
}
|
||||||
|
identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
|
||||||
races, _, err := s.fetchSeasonRaces(year)
|
races, racesIncomplete, err := fetchSeasonRaces(client, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
return nil, false, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
incomplete := identityIncomplete || racesIncomplete
|
||||||
|
resp.Source = "openf1"
|
||||||
|
if identityIncomplete {
|
||||||
|
resp.Enrichment = "limited"
|
||||||
|
} else {
|
||||||
|
resp.Enrichment = "full"
|
||||||
|
}
|
||||||
|
return &resp, incomplete, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDriverPresentation(driver models.Driver) bool {
|
||||||
|
hasName := driver.FullName != "" || driver.NameAcronym != "" || driver.BroadcastName != ""
|
||||||
|
return hasName && driver.TeamName != "" && driver.TeamColour != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryEnrichDriverSummary optionally fills headshot / polished identity from
|
||||||
|
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
|
||||||
|
// failure the local profile remains intact with enrichment=limited.
|
||||||
|
func tryEnrichDriverSummary(parent context.Context, client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) {
|
||||||
|
if resp == nil || client == nil || sessionKey <= 0 {
|
||||||
|
if resp != nil && resp.Enrichment == "none" {
|
||||||
|
// No session to enrich from — leave as none (local identity only).
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, resp)
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, driverEnrichmentTimeout)
|
||||||
|
defer cancel()
|
||||||
|
driver, err := client.GetDriverContext(ctx, sessionKey, resp.DriverNumber)
|
||||||
|
if err != nil || driver == nil {
|
||||||
|
resp.Enrichment = "limited"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyDriverEnrichment(resp, *driver)
|
||||||
|
resp.Enrichment = "full"
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
|
||||||
|
if d.HeadshotURL != "" {
|
||||||
|
resp.HeadshotURL = d.HeadshotURL
|
||||||
|
}
|
||||||
|
if d.FullName != "" {
|
||||||
|
resp.FullName = d.FullName
|
||||||
|
}
|
||||||
|
if d.NameAcronym != "" {
|
||||||
|
resp.NameAcronym = d.NameAcronym
|
||||||
|
}
|
||||||
|
if d.TeamName != "" {
|
||||||
|
resp.TeamName = d.TeamName
|
||||||
|
}
|
||||||
|
if d.TeamColour != "" {
|
||||||
|
resp.TeamColour = d.TeamColour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||||
@@ -134,3 +139,234 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
meetingKey := 1201
|
||||||
|
sessionKey := 9901
|
||||||
|
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
MeetingName: "Bahrain GP",
|
||||||
|
CountryCode: "BHR",
|
||||||
|
CountryName: "Bahrain",
|
||||||
|
Year: 2025,
|
||||||
|
DateStart: "2025-03-02",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertMeeting: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
SessionName: "Race",
|
||||||
|
SessionType: "Race",
|
||||||
|
DateStart: "2025-03-02T15:00:00Z",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertDriver(store.Driver{
|
||||||
|
DriverNumber: 1,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
NameAcronym: "VER",
|
||||||
|
TeamName: "Red Bull",
|
||||||
|
TeamColour: "3671c6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertDriver: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSessionDriver(store.SessionDriver{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
NameAcronym: "VER",
|
||||||
|
TeamName: "Red Bull",
|
||||||
|
TeamColour: "3671c6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSessionDriver: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSessionResult(store.SessionResult{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
Position: 1,
|
||||||
|
Points: 25,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSessionResult: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
Position: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertStartingGridEntry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
|
||||||
|
prev := driverEnrichmentTimeout
|
||||||
|
driverEnrichmentTimeout = 40 * time.Millisecond
|
||||||
|
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||||
|
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
|
||||||
|
release := make(chan struct{})
|
||||||
|
cancelObserved := make(chan struct{})
|
||||||
|
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
close(cancelObserved)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
hang.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if elapsed > 500*time.Millisecond {
|
||||||
|
t.Fatalf("handler blocked on enrichment for %v", elapsed)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cancelObserved:
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
t.Fatal("timed-out enrichment did not cancel its upstream request")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp driverSummaryResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Source != "local" {
|
||||||
|
t.Errorf("source = %q, want local", resp.Source)
|
||||||
|
}
|
||||||
|
if resp.Enrichment != "limited" {
|
||||||
|
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||||
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
|
||||||
|
t.Errorf("local identity/results missing: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
|
||||||
|
prev := driverEnrichmentTimeout
|
||||||
|
driverEnrichmentTimeout = 200 * time.Millisecond
|
||||||
|
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||||
|
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
t.Cleanup(fail.Close)
|
||||||
|
|
||||||
|
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp driverSummaryResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Source != "local" {
|
||||||
|
t.Errorf("source = %q, want local", resp.Source)
|
||||||
|
}
|
||||||
|
if resp.Enrichment != "limited" {
|
||||||
|
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||||
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
if resp.FullName != "Max Verstappen" {
|
||||||
|
t.Errorf("full_name = %q, want local identity", resp.FullName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
// Even with a broken OpenF1 client, source=local must succeed from the DB.
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
t.Cleanup(fail.Close)
|
||||||
|
client := api.NewOpenF1Client(fail.URL, time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" {
|
||||||
|
t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRemoteDriverSummaryReportsIdentityAndRoundLimitations(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
driversOK bool
|
||||||
|
meetingHasRace bool
|
||||||
|
wantEnrichment string
|
||||||
|
}{
|
||||||
|
{name: "missing identity", driversOK: false, meetingHasRace: true, wantEnrichment: "limited"},
|
||||||
|
{name: "missing race round", driversOK: true, meetingHasRace: false, wantEnrichment: "full"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
upstream := championshipTestUpstream(t, tt.driversOK, tt.meetingHasRace)
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
server := NewServer(client, 0, nil)
|
||||||
|
year := time.Now().Year()
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/driver/summary?year=%d&driver_number=1&source=openf1", year), nil)
|
||||||
|
|
||||||
|
server.handleDriverSummary(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
var response driverSummaryResponse
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.Enrichment != tt.wantEnrichment {
|
||||||
|
t.Fatalf("enrichment = %q, want %q", response.Enrichment, tt.wantEnrichment)
|
||||||
|
}
|
||||||
|
if recorder.Header().Get(dataSourceHeader) != "openf1" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
|
||||||
|
t.Fatalf("remote limitation metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
65
internal/web/freshness.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataSourceHeader = "X-BoxBox-Data-Source"
|
||||||
|
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staleResponseReporter interface {
|
||||||
|
LastResponseWasStale() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// markOpenF1Response publishes request-scoped success provenance. Callers must
|
||||||
|
// pass the scoped client used for this response, never Server.client.
|
||||||
|
func markOpenF1Response(w http.ResponseWriter, client staleResponseReporter) {
|
||||||
|
markOpenF1AggregateResponse(w, client, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markOpenF1AggregateResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
|
||||||
|
freshness := "fresh"
|
||||||
|
if partial {
|
||||||
|
freshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, freshness)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markOpenF1Availability(w http.ResponseWriter, client staleResponseReporter, freshness string) {
|
||||||
|
w.Header().Set(dataSourceHeader, "openf1")
|
||||||
|
if client != nil && client.LastResponseWasStale() {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if freshness == "" {
|
||||||
|
freshness = "fresh"
|
||||||
|
}
|
||||||
|
w.Header().Set(dataFreshnessHeader, freshness)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markDataResponse(w http.ResponseWriter, source, freshness string) {
|
||||||
|
w.Header().Set(dataSourceHeader, source)
|
||||||
|
w.Header().Set(dataFreshnessHeader, freshness)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLocalResponse(w http.ResponseWriter, partial bool) {
|
||||||
|
w.Header().Set(dataSourceHeader, "local")
|
||||||
|
if partial {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "partial")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set(dataFreshnessHeader, "local")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMixedResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
|
||||||
|
w.Header().Set(dataSourceHeader, "mixed")
|
||||||
|
if client != nil && client.LastResponseWasStale() {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
|
} else if partial {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "partial")
|
||||||
|
} else {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "local")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/web/freshness_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStaleReporter bool
|
||||||
|
|
||||||
|
func (f fakeStaleReporter) LastResponseWasStale() bool { return bool(f) }
|
||||||
|
|
||||||
|
func TestStaleFreshnessTakesPrecedenceOverPartialAndLimited(t *testing.T) {
|
||||||
|
for _, fallback := range []string{"partial", "limited"} {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
markOpenF1Availability(recorder, fakeStaleReporter(true), fallback)
|
||||||
|
if recorder.Header().Get(dataFreshnessHeader) != "stale" {
|
||||||
|
t.Fatalf("fallback %q overrode stale: %q", fallback, recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
|
||||||
|
year := time.Now().Year()
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
|
||||||
|
}))
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
server := NewServer(client, 0, nil)
|
||||||
|
cacheKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/meetings?year=%d&source=openf1", year)
|
||||||
|
defer func() {
|
||||||
|
// Keep the shared application cache clean even if this test fails.
|
||||||
|
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
|
||||||
|
if err == nil {
|
||||||
|
_, _ = db.Exec(`DELETE FROM cache WHERE key = ?`, cacheKey)
|
||||||
|
_ = db.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fresh := httptest.NewRecorder()
|
||||||
|
server.handleMeetings(fresh, httptest.NewRequest(http.MethodGet, requestURL, nil))
|
||||||
|
if fresh.Code != http.StatusOK || fresh.Header().Get(dataSourceHeader) != "openf1" || fresh.Header().Get(dataFreshnessHeader) != "fresh" {
|
||||||
|
t.Fatalf("fresh response status/metadata = %d %q/%q body=%s", fresh.Code, fresh.Header().Get(dataSourceHeader), fresh.Header().Get(dataFreshnessHeader), fresh.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), cacheKey); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = db.Close()
|
||||||
|
upstream.Close()
|
||||||
|
|
||||||
|
stale := httptest.NewRecorder()
|
||||||
|
server.handleMeetings(stale, httptest.NewRequest(http.MethodGet, requestURL, nil))
|
||||||
|
if stale.Code != http.StatusOK || stale.Header().Get(dataSourceHeader) != "openf1" || stale.Header().Get(dataFreshnessHeader) != "stale" {
|
||||||
|
t.Fatalf("stale response status/metadata = %d %q/%q body=%s", stale.Code, stale.Header().Get(dataSourceHeader), stale.Header().Get(dataFreshnessHeader), stale.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshnessHeadersAreExposedToBrowserClients(t *testing.T) {
|
||||||
|
handler := withCORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
markDataResponse(w, "local", "partial")
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test", nil))
|
||||||
|
|
||||||
|
if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != dataSourceHeader+", "+dataFreshnessHeader {
|
||||||
|
t.Fatalf("exposed headers = %q", got)
|
||||||
|
}
|
||||||
|
if recorder.Header().Get(dataSourceHeader) != "local" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
|
||||||
|
t.Fatalf("data metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
if snapshotIsLive(data) {
|
||||||
h.isLive = true
|
h.isLive = true
|
||||||
h.activeSnapshot = &data
|
h.activeSnapshot = &data
|
||||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||||
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotIsLive reports whether the current live-timing snapshot represents an
|
||||||
|
// ongoing session that should be surfaced as live.
|
||||||
|
//
|
||||||
|
// An actively running session (Started/Resumed) is always live. A non-terminal
|
||||||
|
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
|
||||||
|
// drops to "Inactive" while the session is still in progress — is also live when
|
||||||
|
// the snapshot itself carries live evidence: a session clock with time remaining
|
||||||
|
// plus session metadata. This keeps the paused track state visible instead of
|
||||||
|
// collapsing to the inactive empty state.
|
||||||
|
//
|
||||||
|
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
|
||||||
|
// inactive/no-session stale snapshot is not live either: the predicate keys off
|
||||||
|
// the current snapshot's clock and session, so old metadata alone is not enough.
|
||||||
|
func snapshotIsLive(data live.LiveStreamData) bool {
|
||||||
|
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if live.SessionStatusIsTerminal(data.SessionStatus) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
|
||||||
|
// left. Empty or all-zero clocks (a spent or absent session) return false.
|
||||||
|
func clockHasTimeRemaining(clock string) bool {
|
||||||
|
for _, r := range clock {
|
||||||
|
if r >= '1' && r <= '9' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
||||||
return len(data.Drivers) > 0 ||
|
return len(data.Drivers) > 0 ||
|
||||||
len(data.DriverInfo) > 0 ||
|
len(data.DriverInfo) > 0 ||
|
||||||
@@ -330,7 +364,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
|
|||||||
|
|
||||||
// handleLiveState returns the current live data snapshot as JSON.
|
// handleLiveState returns the current live data snapshot as JSON.
|
||||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, s.hub.State())
|
state := s.hub.State()
|
||||||
|
freshness := "limited"
|
||||||
|
if state.IsLive {
|
||||||
|
freshness = "live"
|
||||||
|
} else if state.LastSnapshot != nil {
|
||||||
|
freshness = "archive"
|
||||||
|
}
|
||||||
|
markDataResponse(w, "fia", freshness)
|
||||||
|
writeJSON(w, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSSEStream is the persistent SSE endpoint for live data.
|
// handleSSEStream is the persistent SSE endpoint for live data.
|
||||||
|
|||||||
@@ -51,6 +51,101 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSnapshotIsLive(t *testing.T) {
|
||||||
|
redFlag := live.LiveStreamData{
|
||||||
|
SessionStatus: "Inactive",
|
||||||
|
TrackStatus: "2",
|
||||||
|
Clock: "00:03:27",
|
||||||
|
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data live.LiveStreamData
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
|
||||||
|
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
|
||||||
|
{"red flag inactive with clock and session", redFlag, true},
|
||||||
|
{"terminal finished with clock", func() live.LiveStreamData {
|
||||||
|
d := redFlag
|
||||||
|
d.SessionStatus = "Finished"
|
||||||
|
return d
|
||||||
|
}(), false},
|
||||||
|
{"inactive spent clock", func() live.LiveStreamData {
|
||||||
|
d := redFlag
|
||||||
|
d.Clock = "00:00:00"
|
||||||
|
return d
|
||||||
|
}(), false},
|
||||||
|
{"inactive no clock", func() live.LiveStreamData {
|
||||||
|
d := redFlag
|
||||||
|
d.Clock = ""
|
||||||
|
return d
|
||||||
|
}(), false},
|
||||||
|
{"inactive no session metadata", func() live.LiveStreamData {
|
||||||
|
d := redFlag
|
||||||
|
d.Session = live.LiveSessionMeta{}
|
||||||
|
return d
|
||||||
|
}(), false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := snapshotIsLive(tt.data); got != tt.want {
|
||||||
|
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
|
||||||
|
hub := newSSEHub()
|
||||||
|
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
redFlag := live.LiveStreamData{
|
||||||
|
SessionStatus: "Inactive",
|
||||||
|
TrackStatus: "2",
|
||||||
|
Clock: "00:03:27",
|
||||||
|
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
||||||
|
SnapshotUpdated: true,
|
||||||
|
}
|
||||||
|
state := hub.applySnapshot(redFlag, now)
|
||||||
|
|
||||||
|
if !state.IsLive {
|
||||||
|
t.Fatal("red-flag paused session should report is_live=true")
|
||||||
|
}
|
||||||
|
if state.Data == nil {
|
||||||
|
t.Fatal("red-flag paused session must still send the active snapshot")
|
||||||
|
}
|
||||||
|
if state.Data.TrackStatus != "2" {
|
||||||
|
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
|
||||||
|
}
|
||||||
|
if state.LastSnapshot != nil {
|
||||||
|
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
|
||||||
|
hub := newSSEHub()
|
||||||
|
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Inactive snapshot with session metadata but no remaining clock: this is a
|
||||||
|
// stale/no-live-evidence snapshot and must not be surfaced as live.
|
||||||
|
stale := live.LiveStreamData{
|
||||||
|
SessionStatus: "Inactive",
|
||||||
|
Clock: "00:00:00",
|
||||||
|
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
|
||||||
|
SnapshotUpdated: true,
|
||||||
|
}
|
||||||
|
state := hub.applySnapshot(stale, now)
|
||||||
|
|
||||||
|
if state.IsLive {
|
||||||
|
t.Fatal("stale inactive snapshot should report is_live=false")
|
||||||
|
}
|
||||||
|
if state.Data != nil {
|
||||||
|
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
|
||||||
|
}
|
||||||
|
if state.LastSnapshot == nil {
|
||||||
|
t.Fatal("stale inactive snapshot with metadata should be archived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
||||||
hub := newSSEHub()
|
hub := newSSEHub()
|
||||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []int{})
|
writeJSON(w, []int{})
|
||||||
return
|
return
|
||||||
@@ -26,6 +27,7 @@ func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
|
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
|
||||||
if err != nil || meetingKey == 0 {
|
if err != nil || meetingKey == 0 {
|
||||||
http.Error(w, "meeting_key required", http.StatusBadRequest)
|
http.Error(w, "meeting_key required", http.StatusBadRequest)
|
||||||
@@ -46,5 +48,6 @@ func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err, http.StatusInternalServerError, false)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
|
||||||
writeJSON(w, weekend)
|
writeJSON(w, weekend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
writeJSON(w, emptyRaceHub(sessionKey))
|
writeJSON(w, emptyRaceHub(sessionKey))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,14 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err, http.StatusInternalServerError, false)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
switch hub.Source {
|
||||||
|
case query.ResponseSourceNone:
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
|
case query.ResponseSourcePartial:
|
||||||
|
markLocalResponse(w, true)
|
||||||
|
default:
|
||||||
|
markLocalResponse(w, false)
|
||||||
|
}
|
||||||
writeJSON(w, hub)
|
writeJSON(w, hub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,11 +86,29 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
|
|||||||
if hub.Source != query.ResponseSourceNone {
|
if hub.Source != query.ResponseSourceNone {
|
||||||
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
|
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
|
||||||
}
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("missing hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
|
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
|
||||||
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
|
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRaceHubUnknownSessionWithStoreReportsLimited(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
srv := testServer(t, st)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleRaceHub(rec, httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=999999", nil))
|
||||||
|
|
||||||
|
var hub query.RaceHub
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hub.Source != query.ResponseSourceNone || rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("unknown session = source %q, metadata %q/%q", hub.Source, rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleRaceHubWithLocalData(t *testing.T) {
|
func TestHandleRaceHubWithLocalData(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
seedRaceHubStore(t, st)
|
seedRaceHubStore(t, st)
|
||||||
@@ -117,6 +135,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
|||||||
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
|
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
|
||||||
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
|
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
|
||||||
}
|
}
|
||||||
|
if hub.Source != query.ResponseSourcePartial {
|
||||||
|
t.Fatalf("source = %q, want partial", hub.Source)
|
||||||
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "partial" {
|
||||||
|
t.Fatalf("partial hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||||
|
|||||||