mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 12:07:23 -04:00
Compare commits
5 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24ec4be94 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: groom
|
name: groom
|
||||||
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
|
description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
|
||||||
argument-hint: <issue-number> [--lens architect]
|
argument-hint: <issue-number> [--lens architect]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: implement
|
name: implement
|
||||||
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.
|
description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
|
||||||
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: lens-architect
|
name: lens-architect
|
||||||
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
|
description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
|
||||||
argument-hint: <issue-number>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: review
|
name: review
|
||||||
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
|
description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
|
||||||
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: write-spec
|
name: write-spec
|
||||||
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage.
|
description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
|
||||||
argument-hint: <issue-number>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type {
|
|||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
|
EnrichedGrid,
|
||||||
|
EnrichedResult,
|
||||||
LapsComparisonResponse,
|
LapsComparisonResponse,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
LiveSessionMeta,
|
LiveSessionMeta,
|
||||||
@@ -40,7 +42,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
return fetchMeetings(year, 'openf1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
||||||
|
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
}
|
}
|
||||||
@@ -48,6 +54,32 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
|||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
||||||
|
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const results = await res.json()
|
||||||
|
return Array.isArray(results) ? results : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
||||||
|
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const grid = await res.json()
|
||||||
|
return Array.isArray(grid) ? grid : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
||||||
|
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||||
|
return data as TrackOutline
|
||||||
|
}
|
||||||
|
|
||||||
export async function 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) {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export function Nav() {
|
|||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||||
|
Preview
|
||||||
|
</Link>
|
||||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||||
Championship
|
Championship
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchNews } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
} from '../lib/digest'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
const SOURCE_DISPLAY: Record<string, string> = {
|
const SOURCE_DISPLAY: Record<string, string> = {
|
||||||
'fia': 'FIA',
|
'fia': 'FIA',
|
||||||
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PaddockBriefing() {
|
export function PaddockBriefing() {
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: () => fetchNews(100),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', latestSeason],
|
||||||
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const hub = hubQuery.data
|
||||||
|
const tagged = useMemo(
|
||||||
|
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
||||||
|
[news, hub],
|
||||||
|
)
|
||||||
|
|
||||||
|
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||||
|
const activeWindow = useMemo(
|
||||||
|
() => activeDigestWindow(windows, meetings, now),
|
||||||
|
[windows, meetings, now],
|
||||||
|
)
|
||||||
|
const sinceItems = useMemo(
|
||||||
|
() => itemsForWindow(tagged, activeWindow),
|
||||||
|
[tagged, activeWindow],
|
||||||
|
)
|
||||||
|
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
||||||
|
const sinceLabel = sinceLastLabel(meetings, now)
|
||||||
|
|
||||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||||
const preview = news?.slice(0, 5) ?? []
|
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||||
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
||||||
|
<span>Since {sinceLabel}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{sinceItems.length} items</span>
|
||||||
|
{sinceTags.length > 0 && (
|
||||||
|
<div className="cc-brief-digest-tags">
|
||||||
|
{sinceTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.key}
|
||||||
|
className="cc-brief-tag"
|
||||||
|
style={{ borderColor: tagColour(tag.colour) }}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||||
|
|
||||||
|
|||||||
293
frontend/src/lib/digest.ts
Normal file
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
|
||||||
|
})
|
||||||
|
}
|
||||||
123
frontend/src/lib/preview.ts
Normal file
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||||
|
import {
|
||||||
|
currentAndNextSession,
|
||||||
|
nextUpcomingMeeting,
|
||||||
|
sessionStartTime,
|
||||||
|
sortSessionsByStart,
|
||||||
|
} from './schedule'
|
||||||
|
|
||||||
|
export const RACE_WIN_POINTS = 25
|
||||||
|
export const SPRINT_WIN_POINTS = 8
|
||||||
|
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
|
||||||
|
|
||||||
|
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||||
|
const active = meetings.filter((m) => !m.is_cancelled)
|
||||||
|
return nextUpcomingMeeting(active, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPriorYearMeetingByCircuit(
|
||||||
|
priorYearMeetings: Meeting[],
|
||||||
|
circuitKey: number | undefined,
|
||||||
|
): Meeting | null {
|
||||||
|
if (!circuitKey) return null
|
||||||
|
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRaceSession(sessions: Session[]): Session | null {
|
||||||
|
const sorted = sortSessionsByStart(sessions)
|
||||||
|
return (
|
||||||
|
sorted.find((s) => {
|
||||||
|
const type = s.session_type?.toLowerCase() ?? ''
|
||||||
|
return type.includes('race') && !type.includes('sprint')
|
||||||
|
}) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSprintWeekend(sessions: Session[]): boolean {
|
||||||
|
return sessions.some((s) => {
|
||||||
|
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
|
||||||
|
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodiumPlace {
|
||||||
|
position: number
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolePosition {
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
|
||||||
|
return results
|
||||||
|
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((r) => ({
|
||||||
|
position: r.position,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
full_name: r.full_name,
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
|
||||||
|
const pole = grid.find((g) => g.position === 1)
|
||||||
|
if (!pole) return null
|
||||||
|
return {
|
||||||
|
name_acronym: pole.name_acronym,
|
||||||
|
full_name: pole.full_name,
|
||||||
|
team_name: pole.team_name,
|
||||||
|
team_colour: pole.team_colour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitleFightDriver {
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_colour: string
|
||||||
|
points: number
|
||||||
|
position: number
|
||||||
|
gapToLeader: number
|
||||||
|
gapAfterRaceWin: number
|
||||||
|
gapAfterSprintWeekendMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
|
||||||
|
if (hub.drivers.length === 0) return []
|
||||||
|
const leader = hub.drivers[0]
|
||||||
|
return hub.drivers.slice(0, 3).map((d) => ({
|
||||||
|
name_acronym: d.name_acronym,
|
||||||
|
full_name: d.full_name,
|
||||||
|
team_colour: d.team_colour,
|
||||||
|
points: d.points,
|
||||||
|
position: d.position,
|
||||||
|
gapToLeader: leader.points - d.points,
|
||||||
|
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
|
||||||
|
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
|
||||||
|
const sorted = sortSessionsByStart(sessions)
|
||||||
|
const { next } = currentAndNextSession(sorted, now)
|
||||||
|
if (next) return next
|
||||||
|
for (const session of sorted) {
|
||||||
|
const start = sessionStartTime(session)
|
||||||
|
if (start && start > now) return session
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPointsGap(gap: number): string {
|
||||||
|
if (gap === 0) return 'LEADER'
|
||||||
|
const sign = gap > 0 ? '+' : ''
|
||||||
|
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchNewsArticle,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
markNewsRead,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
filterByTag,
|
||||||
|
groupByWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
sortWindowBucketsNewestFirst,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
type DigestTag,
|
||||||
|
type TaggedNewsItem,
|
||||||
|
} from '../lib/digest'
|
||||||
import { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
|
|
||||||
const PAGE_SIZE = 16
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<Category, string> = {
|
const CATEGORY_LABELS: Record<Category, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
official: 'Official',
|
official: 'Official',
|
||||||
@@ -71,6 +91,31 @@ function CategoryTabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TagChip({
|
||||||
|
tag,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
className = 'digest-tag-chip',
|
||||||
|
}: {
|
||||||
|
tag: DigestTag
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${className}${active ? ' active' : ''}`}
|
||||||
|
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="digest-tag-dot" aria-hidden="true" />
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function OGImage({ url, title }: { url?: string; title: string }) {
|
function OGImage({ url, title }: { url?: string; title: string }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const initial = (title[0] ?? '?').toUpperCase()
|
const initial = (title[0] ?? '?').toUpperCase()
|
||||||
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
|
|||||||
function BriefingCard({
|
function BriefingCard({
|
||||||
item,
|
item,
|
||||||
isActive,
|
isActive,
|
||||||
|
activeTag,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onTagClick,
|
||||||
}: {
|
}: {
|
||||||
item: NewsItem
|
item: TaggedNewsItem
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
activeTag: string | null
|
||||||
onSelect: (item: NewsItem) => void
|
onSelect: (item: NewsItem) => void
|
||||||
|
onTagClick: (tag: DigestTag) => void
|
||||||
}) {
|
}) {
|
||||||
const isRead = !!item.read_at
|
const isRead = !!item.read_at
|
||||||
const isVideo = categoryOf(item) === 'video'
|
const isVideo = categoryOf(item) === 'video'
|
||||||
@@ -129,6 +178,25 @@ function BriefingCard({
|
|||||||
{stripHtml(item.og_description || item.summary || '')}
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="bp-card-tags">
|
||||||
|
{item.tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.key}
|
||||||
|
type="button"
|
||||||
|
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
|
||||||
|
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onTagClick(tag)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="digest-tag-dot" aria-hidden="true" />
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -146,7 +214,6 @@ function ReaderPanel({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -155,7 +222,6 @@ function ReaderPanel({
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
// Fetch article when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
setArticle(null)
|
setArticle(null)
|
||||||
@@ -175,9 +241,14 @@ function ReaderPanel({
|
|||||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||||
}, [item?.url, item ? categoryOf(item) : ''])
|
}, [item?.url, item ? categoryOf(item) : ''])
|
||||||
|
|
||||||
// Scroll panel to top when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
panelRef.current?.scrollTo({ top: 0 })
|
const panel = panelRef.current
|
||||||
|
if (!panel) return
|
||||||
|
if (typeof panel.scrollTo === 'function') {
|
||||||
|
panel.scrollTo({ top: 0 })
|
||||||
|
} else {
|
||||||
|
panel.scrollTop = 0
|
||||||
|
}
|
||||||
}, [item?.url])
|
}, [item?.url])
|
||||||
|
|
||||||
const isOpen = item !== null
|
const isOpen = item !== null
|
||||||
@@ -297,7 +368,6 @@ function ReaderPanel({
|
|||||||
{article && !loading && article.content && (
|
{article && !loading && article.content && (
|
||||||
<div
|
<div
|
||||||
className="bp-reader-body"
|
className="bp-reader-body"
|
||||||
// readability strips scripts/iframes; sources are all known news outlets
|
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||||
/>
|
/>
|
||||||
@@ -332,8 +402,9 @@ function ReaderPanel({
|
|||||||
export function BriefingPage() {
|
export function BriefingPage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
@@ -341,13 +412,74 @@ export function BriefingPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = allNews.filter(
|
const seasonsQuery = useQuery({
|
||||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', latestSeason],
|
||||||
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const hub = hubQuery.data
|
||||||
|
|
||||||
|
const taggedNews = useMemo(
|
||||||
|
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
|
||||||
|
[allNews, hub],
|
||||||
)
|
)
|
||||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
||||||
const safePage = Math.min(page, pageCount)
|
const categoryFiltered = useMemo(
|
||||||
const pageStart = (safePage - 1) * PAGE_SIZE
|
() => taggedNews.filter(
|
||||||
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||||
|
),
|
||||||
|
[taggedNews, activeCategory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagFiltered = useMemo(
|
||||||
|
() => filterByTag(categoryFiltered, activeTag),
|
||||||
|
[categoryFiltered, activeTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||||
|
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
|
||||||
|
const windowSections = useMemo(
|
||||||
|
() => sortWindowBucketsNewestFirst(grouped.windows),
|
||||||
|
[grouped.windows],
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeWindow = useMemo(
|
||||||
|
() => activeDigestWindow(windows, meetings, now),
|
||||||
|
[windows, meetings, now],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sinceLastItems = useMemo(
|
||||||
|
() => itemsForWindow(tagFiltered, activeWindow),
|
||||||
|
[tagFiltered, activeWindow],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
|
||||||
|
const sinceLabel = sinceLastLabel(meetings, now)
|
||||||
|
|
||||||
|
const taggedByUrl = useMemo(() => {
|
||||||
|
const map = new Map<string, TaggedNewsItem>()
|
||||||
|
for (const item of tagFiltered) map.set(item.url, item)
|
||||||
|
return map
|
||||||
|
}, [tagFiltered])
|
||||||
|
|
||||||
|
const recentTagged = useMemo(() => {
|
||||||
|
const urls = new Set(grouped.recent.map((item) => item.url))
|
||||||
|
return tagFiltered.filter((item) => urls.has(item.url))
|
||||||
|
}, [grouped.recent, tagFiltered])
|
||||||
|
|
||||||
const counts: Record<Category, number> = {
|
const counts: Record<Category, number> = {
|
||||||
all: allNews.length,
|
all: allNews.length,
|
||||||
@@ -374,22 +506,14 @@ export function BriefingPage() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTagClick = useCallback((tag: DigestTag) => {
|
||||||
setPage((current) => Math.min(current, pageCount))
|
setActiveTag((current) => (current === tag.key ? null : tag.key))
|
||||||
}, [pageCount])
|
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
|
||||||
(nextPage: number) => {
|
|
||||||
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
}, [])
|
||||||
},
|
|
||||||
[pageCount],
|
|
||||||
)
|
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||||
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
const hasDigest = meetings.length > 0
|
||||||
const showPagination = filtered.length > PAGE_SIZE
|
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
@@ -408,74 +532,106 @@ export function BriefingPage() {
|
|||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
onChange={(c) => {
|
||||||
|
setActiveCategory(c)
|
||||||
|
setSelectedItem(null)
|
||||||
|
setActiveTag(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{hasDigest && (
|
||||||
<div className="bp-empty">
|
<div className="digest-sticky" data-testid="digest-sticky-header">
|
||||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
<div className="digest-sticky-head">
|
||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
||||||
</div>
|
<span className="digest-sticky-count">
|
||||||
) : (
|
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
||||||
<>
|
|
||||||
{showPagination && (
|
|
||||||
<div className="bp-pagination bp-pagination-top">
|
|
||||||
<span className="bp-pagination-meta mono">
|
|
||||||
{pageStart + 1}-{pageEnd} of {filtered.length}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="bp-pagination-actions">
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="bp-page-current mono">
|
|
||||||
Page {safePage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage + 1)}
|
|
||||||
disabled={safePage === pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{(sinceLastTags.length > 0 || activeTag) && (
|
||||||
|
<div className="digest-sticky-tags">
|
||||||
|
{sinceLastTags.map((tag) => (
|
||||||
|
<TagChip
|
||||||
|
key={tag.key}
|
||||||
|
tag={tag}
|
||||||
|
active={activeTag === tag.key}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeTag && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="digest-filter-clear"
|
||||||
|
onClick={() => setActiveTag(null)}
|
||||||
|
data-testid="digest-filter-clear"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<div className="bp-empty">
|
||||||
|
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
||||||
|
{activeTag ? ' Try clearing the tag filter.' : ''}
|
||||||
|
{!activeTag && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{windowSections.map(({ window, items }) => (
|
||||||
|
<section
|
||||||
|
key={window.meeting_key}
|
||||||
|
className="digest-section"
|
||||||
|
data-testid={`digest-window-${window.meeting_key}`}
|
||||||
|
>
|
||||||
|
<div className="digest-section-head">
|
||||||
|
<h3 className="digest-section-title">{window.meeting_name}</h3>
|
||||||
|
<span className="digest-section-count">{items.length}</span>
|
||||||
|
</div>
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
{pagedItems.map((item) => (
|
{items.map((item) => {
|
||||||
|
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
|
||||||
|
return (
|
||||||
|
<BriefingCard
|
||||||
|
key={item.url}
|
||||||
|
item={tagged}
|
||||||
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{recentTagged.length > 0 && (
|
||||||
|
<section className="digest-section digest-recent-section" data-testid="digest-recent">
|
||||||
|
<div className="digest-section-head">
|
||||||
|
<h3 className="digest-section-title">Recent</h3>
|
||||||
|
<span className="digest-section-count">{recentTagged.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
|
{recentTagged.map((item) => (
|
||||||
<BriefingCard
|
<BriefingCard
|
||||||
key={item.url}
|
key={item.url}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={selectedItem?.url === item.url}
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{showPagination && (
|
|
||||||
<div className="bp-pagination bp-pagination-bottom">
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="bp-page-current mono">
|
|
||||||
Page {safePage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage + 1)}
|
|
||||||
disabled={safePage === pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchMeetings,
|
||||||
|
fetchResults,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchSessions,
|
||||||
|
fetchStartingGrid,
|
||||||
|
fetchTrackOutline,
|
||||||
|
} from '../api'
|
||||||
|
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import {
|
||||||
|
buildTitleFightContext,
|
||||||
|
countdownTargetSession,
|
||||||
|
extractPodium,
|
||||||
|
extractPole,
|
||||||
|
findPriorYearMeetingByCircuit,
|
||||||
|
findRaceSession,
|
||||||
|
formatPointsGap,
|
||||||
|
isSprintWeekend,
|
||||||
|
pickPreviewMeeting,
|
||||||
|
RACE_WIN_POINTS,
|
||||||
|
SPRINT_WEEKEND_MAX_POINTS,
|
||||||
|
} from '../lib/preview'
|
||||||
|
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
|
||||||
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import type { Meeting, Session, TrackOutline } from '../types'
|
||||||
|
import '../styles/preview.css'
|
||||||
|
|
||||||
|
function SectionState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
empty?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="preview-section-state">Loading…</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="preview-section-state error">
|
||||||
|
{error instanceof Error ? error.message : 'Failed to load'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (empty) {
|
||||||
|
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackOutlineCard({
|
||||||
|
outline,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
outline: TrackOutline | null | undefined
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="preview-card" data-testid="preview-track-card">
|
||||||
|
<h2 className="preview-card-title">Circuit</h2>
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={!outline || !outlinePath}
|
||||||
|
emptyMessage="Track outline unavailable for this circuit"
|
||||||
|
>
|
||||||
|
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
||||||
|
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
|
||||||
|
<path className="preview-track-shadow" d={outlinePath} />
|
||||||
|
<path className="preview-track-outline" d={outlinePath} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastYearCard({
|
||||||
|
year,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
podium,
|
||||||
|
pole,
|
||||||
|
isFirstTime,
|
||||||
|
}: {
|
||||||
|
year: number | null
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
podium: ReturnType<typeof extractPodium>
|
||||||
|
pole: ReturnType<typeof extractPole>
|
||||||
|
isFirstTime: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="preview-card" data-testid="preview-last-year-card">
|
||||||
|
<h2 className="preview-card-title">Last year here</h2>
|
||||||
|
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={isFirstTime || podium.length === 0}
|
||||||
|
emptyMessage={
|
||||||
|
isFirstTime
|
||||||
|
? 'First time on the calendar — no prior race at this circuit'
|
||||||
|
: 'No race results available for the previous visit'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="preview-podium">
|
||||||
|
{podium.map((place) => (
|
||||||
|
<div key={place.position} className="preview-podium-row">
|
||||||
|
<span className="preview-podium-pos">P{place.position}</span>
|
||||||
|
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
|
||||||
|
{place.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="preview-podium-team">{place.team_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pole && (
|
||||||
|
<p className="preview-pole">
|
||||||
|
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
|
||||||
|
({pole.team_name})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleFightCard({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
drivers,
|
||||||
|
sprintWeekend,
|
||||||
|
season,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
drivers: ReturnType<typeof buildTitleFightContext>
|
||||||
|
sprintWeekend: boolean
|
||||||
|
season: number | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
|
||||||
|
<h2 className="preview-card-title">Title fight context</h2>
|
||||||
|
{season != null && <p className="preview-card-sub">{season} drivers' championship · top 3</p>}
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={drivers.length === 0}
|
||||||
|
emptyMessage="Championship standings not available"
|
||||||
|
>
|
||||||
|
<table className="preview-title-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th>Pts</th>
|
||||||
|
<th>Gap</th>
|
||||||
|
<th>If win (+{RACE_WIN_POINTS})</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<tr key={d.name_acronym}>
|
||||||
|
<td>
|
||||||
|
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
|
||||||
|
</td>
|
||||||
|
<td className="mono">{d.points}</td>
|
||||||
|
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
|
||||||
|
<td className="mono">
|
||||||
|
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{sprintWeekend && (
|
||||||
|
<p className="preview-sprint-note" data-testid="preview-sprint-note">
|
||||||
|
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
|
||||||
|
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewHeader({
|
||||||
|
meeting,
|
||||||
|
sessions,
|
||||||
|
countdownSession,
|
||||||
|
now,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
meeting: Meeting
|
||||||
|
sessions: Session[]
|
||||||
|
countdownSession: Session | null
|
||||||
|
now: Date
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="preview-header"
|
||||||
|
data-testid="preview-header"
|
||||||
|
style={{ ['--preview-accent' as string]: accent }}
|
||||||
|
>
|
||||||
|
<div className="preview-header-accent" />
|
||||||
|
<div className="preview-header-body">
|
||||||
|
<div className="preview-header-top">
|
||||||
|
<div>
|
||||||
|
<h1 className="preview-gp-name">
|
||||||
|
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
|
||||||
|
{meeting.meeting_name}
|
||||||
|
</h1>
|
||||||
|
<p className="preview-circuit">
|
||||||
|
{meeting.circuit_short_name}
|
||||||
|
{meeting.location ? ` · ${meeting.location}` : ''}
|
||||||
|
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{countdownTarget && (
|
||||||
|
<div className="preview-countdown" data-testid="preview-countdown">
|
||||||
|
<span className="preview-countdown-label">
|
||||||
|
{countdownSession?.session_name ?? 'Next session'}
|
||||||
|
</span>
|
||||||
|
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="preview-schedule">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{session.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RacePreviewPage() {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const nowDate = useMemo(() => new Date(now), [now])
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', latestSeason, 'auto'],
|
||||||
|
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewMeeting = useMemo(
|
||||||
|
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||||
|
[meetingsQuery.data, nowDate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionsQuery = useQuery({
|
||||||
|
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||||
|
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||||
|
enabled: previewMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = useMemo(
|
||||||
|
() => sortSessionsByStart(sessionsQuery.data ?? []),
|
||||||
|
[sessionsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
|
||||||
|
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
|
||||||
|
|
||||||
|
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
|
||||||
|
|
||||||
|
const priorMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', priorYear, 'auto'],
|
||||||
|
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||||
|
enabled: priorYear != null && previewMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorMeeting = useMemo(
|
||||||
|
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
|
||||||
|
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
|
||||||
|
)
|
||||||
|
|
||||||
|
const priorSessionsQuery = useQuery({
|
||||||
|
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||||
|
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||||
|
enabled: priorMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorRaceSession = useMemo(
|
||||||
|
() => findRaceSession(priorSessionsQuery.data ?? []),
|
||||||
|
[priorSessionsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const priorResultsQuery = useQuery({
|
||||||
|
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||||
|
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||||
|
enabled: priorRaceSession != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorGridQuery = useQuery({
|
||||||
|
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||||
|
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||||
|
enabled: priorRaceSession != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackOutlineQuery = useQuery({
|
||||||
|
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||||
|
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||||
|
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
|
||||||
|
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
|
||||||
|
const titleFight = useMemo(
|
||||||
|
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
|
||||||
|
[championshipQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const accent = countryAccent(previewMeeting)
|
||||||
|
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||||
|
|
||||||
|
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||||
|
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||||
|
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||||
|
return (
|
||||||
|
<div className="page error-box" data-testid="preview-error">
|
||||||
|
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewMeeting) {
|
||||||
|
return (
|
||||||
|
<div className="preview-page" data-testid="preview-season-over">
|
||||||
|
<div className="preview-empty">
|
||||||
|
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
|
||||||
|
<h1 className="preview-empty-title">Season complete</h1>
|
||||||
|
<p className="preview-empty-sub">
|
||||||
|
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
|
||||||
|
published.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{titleFight.length > 0 && (
|
||||||
|
<TitleFightCard
|
||||||
|
loading={championshipQuery.isLoading}
|
||||||
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||||
|
drivers={titleFight}
|
||||||
|
sprintWeekend={false}
|
||||||
|
season={latestSeason}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview-page" data-testid="preview-page">
|
||||||
|
<PreviewHeader
|
||||||
|
meeting={previewMeeting}
|
||||||
|
sessions={sessions}
|
||||||
|
countdownSession={countdownSession}
|
||||||
|
now={nowDate}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="preview-grid">
|
||||||
|
<TrackOutlineCard
|
||||||
|
outline={trackOutlineQuery.data}
|
||||||
|
loading={trackOutlineQuery.isLoading}
|
||||||
|
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LastYearCard
|
||||||
|
year={priorMeeting?.year ?? priorYear}
|
||||||
|
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||||
|
error={
|
||||||
|
priorMeetingsQuery.isError
|
||||||
|
? (priorMeetingsQuery.error as Error)
|
||||||
|
: priorResultsQuery.isError
|
||||||
|
? (priorResultsQuery.error as Error)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
podium={podium}
|
||||||
|
pole={pole}
|
||||||
|
isFirstTime={isFirstTimeCircuit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TitleFightCard
|
||||||
|
loading={championshipQuery.isLoading}
|
||||||
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||||
|
drivers={titleFight}
|
||||||
|
sprintWeekend={sprintWeekend}
|
||||||
|
season={latestSeason}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
|||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
|
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
@@ -70,6 +71,12 @@ export const briefingRoute = createRoute({
|
|||||||
component: BriefingPage,
|
component: BriefingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const previewRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/preview',
|
||||||
|
component: RacePreviewPage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
commandCenterRoute,
|
commandCenterRoute,
|
||||||
raceHubRoute,
|
raceHubRoute,
|
||||||
@@ -78,6 +85,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
championshipRoute,
|
championshipRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
|
previewRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
178
frontend/src/styles/digest.css
Normal file
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);
|
||||||
|
}
|
||||||
309
frontend/src/styles/preview.css
Normal file
309
frontend/src/styles/preview.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
.preview-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s6);
|
||||||
|
padding: var(--s6);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-height: 320px;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--s8);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-eyebrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-accent {
|
||||||
|
width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--preview-accent, var(--gp-accent));
|
||||||
|
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-gp-name {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-gp-flag {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circuit {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown-value {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-time {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-sub {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin: calc(-1 * var(--s2)) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--preview-accent, var(--gp-accent));
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-shadow {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(0, 0, 0, 0.5);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-pos {
|
||||||
|
width: 1.5rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-code {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-team {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pole {
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pole strong {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table th,
|
||||||
|
.preview-title-table td {
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table td.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-sprint-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-state.error {
|
||||||
|
color: var(--red, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.preview-page {
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
209
frontend/src/test/BriefingPage.test.tsx
Normal file
209
frontend/src/test/BriefingPage.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchMeetings: vi.fn(),
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
|
fetchResults: vi.fn(),
|
||||||
|
fetchStartingGrid: vi.fn(),
|
||||||
|
fetchTrackOutline: vi.fn(),
|
||||||
|
fetchChampionshipHub: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchSeasons,
|
||||||
|
fetchMeetings,
|
||||||
|
fetchSessions,
|
||||||
|
fetchResults,
|
||||||
|
fetchStartingGrid,
|
||||||
|
fetchTrackOutline,
|
||||||
|
fetchChampionshipHub,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchMeetings = vi.mocked(fetchMeetings)
|
||||||
|
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||||
|
const mockFetchResults = vi.mocked(fetchResults)
|
||||||
|
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
|
||||||
|
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
||||||
|
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||||
|
|
||||||
|
const upcomingMeeting: Meeting = {
|
||||||
|
meeting_key: 100,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'Monaco GP',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 10,
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2099-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2099-05-24T23:59:59+00:00',
|
||||||
|
year: 2099,
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorMeeting: Meeting = {
|
||||||
|
...upcomingMeeting,
|
||||||
|
meeting_key: 90,
|
||||||
|
year: 2098,
|
||||||
|
date_start: '2098-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2098-05-24T23:59:59+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: Session[] = [
|
||||||
|
{
|
||||||
|
session_key: 1,
|
||||||
|
session_name: 'FP1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 100,
|
||||||
|
date_start: '2099-05-22T10:00:00+00:00',
|
||||||
|
date_end: '2099-05-22T11:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 2,
|
||||||
|
session_name: 'Sprint',
|
||||||
|
session_type: 'Sprint',
|
||||||
|
meeting_key: 100,
|
||||||
|
date_start: '2099-05-23T10:00:00+00:00',
|
||||||
|
date_end: '2099-05-23T11:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 3,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 100,
|
||||||
|
date_start: '2099-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2099-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorRaceSession: Session = {
|
||||||
|
session_key: 50,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 90,
|
||||||
|
date_start: '2098-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2098-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 200,
|
||||||
|
position: 1,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
poles: 4,
|
||||||
|
form: [25],
|
||||||
|
cumulative: [200],
|
||||||
|
teammate_wins: 9,
|
||||||
|
teammate_losses: 1,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2099,
|
||||||
|
round: 5,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 19,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1'],
|
||||||
|
drivers: [
|
||||||
|
hubDriver({}),
|
||||||
|
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||||
|
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 100,
|
||||||
|
gap_to_leader: 0,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 50,
|
||||||
|
meeting_key: 90,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const grid: EnrichedGrid[] = [
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
session_key: 50,
|
||||||
|
meeting_key: 90,
|
||||||
|
lap_duration: 70,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 10,
|
||||||
|
points: [
|
||||||
|
{ x: 0.1, y: 0.2 },
|
||||||
|
{ x: 0.5, y: 0.5 },
|
||||||
|
{ x: 0.9, y: 0.8 },
|
||||||
|
],
|
||||||
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RacePreviewPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RacePreviewPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchSeasons.mockResolvedValue([2099])
|
||||||
|
mockFetchChampionshipHub.mockResolvedValue(hub)
|
||||||
|
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||||
|
mockFetchResults.mockResolvedValue(results)
|
||||||
|
mockFetchStartingGrid.mockResolvedValue(grid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders upcoming race preview with sections', async () => {
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2099) return [upcomingMeeting]
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey === 100) return sessions
|
||||||
|
if (meetingKey === 90) return [priorRaceSession]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
||||||
|
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||||
|
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
|
||||||
|
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders season-over state when no upcoming meeting', async () => {
|
||||||
|
mockFetchMeetings.mockResolvedValue([
|
||||||
|
{
|
||||||
|
...upcomingMeeting,
|
||||||
|
date_start: '2020-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2020-05-24T23:59:59+00:00',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Season complete')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows first-time circuit empty state', async () => {
|
||||||
|
const newCircuit: Meeting = {
|
||||||
|
...upcomingMeeting,
|
||||||
|
meeting_key: 200,
|
||||||
|
circuit_key: 999,
|
||||||
|
meeting_name: 'New GP',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2099) return [newCircuit]
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchSessions.mockResolvedValue(sessions)
|
||||||
|
mockFetchTrackOutline.mockResolvedValue(null)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
265
frontend/src/test/digest.test.ts
Normal file
265
frontend/src/test/digest.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
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,
|
||||||
|
...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)
|
||||||
|
})
|
||||||
|
})
|
||||||
226
frontend/src/test/preview.test.ts
Normal file
226
frontend/src/test/preview.test.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||||
|
import {
|
||||||
|
buildTitleFightContext,
|
||||||
|
countdownTargetSession,
|
||||||
|
extractPodium,
|
||||||
|
extractPole,
|
||||||
|
findPriorYearMeetingByCircuit,
|
||||||
|
findRaceSession,
|
||||||
|
formatPointsGap,
|
||||||
|
isSprintWeekend,
|
||||||
|
pickPreviewMeeting,
|
||||||
|
RACE_WIN_POINTS,
|
||||||
|
SPRINT_WEEKEND_MAX_POINTS,
|
||||||
|
} from '../lib/preview'
|
||||||
|
|
||||||
|
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'Monaco GP',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 10,
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2026-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2026-05-24T23:59:59+00:00',
|
||||||
|
year: 2026,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||||
|
session_key: 100,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2026-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 200,
|
||||||
|
position: 1,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
poles: 4,
|
||||||
|
form: [25],
|
||||||
|
cumulative: [200],
|
||||||
|
teammate_wins: 9,
|
||||||
|
teammate_losses: 1,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('preview lib', () => {
|
||||||
|
it('picks the next upcoming meeting', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const meetings = [
|
||||||
|
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
|
||||||
|
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no upcoming meeting exists', () => {
|
||||||
|
const now = new Date('2027-01-01T00:00:00Z')
|
||||||
|
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips cancelled meetings', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const meetings = [meeting({ is_cancelled: true })]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches prior-year meeting by circuit_key', () => {
|
||||||
|
const prior = [
|
||||||
|
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
|
||||||
|
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
|
||||||
|
]
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds the grand prix race session', () => {
|
||||||
|
const sessions = [
|
||||||
|
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
|
||||||
|
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
|
||||||
|
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
expect(findRaceSession(sessions)?.session_key).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects sprint weekends', () => {
|
||||||
|
const sprint = [
|
||||||
|
session({ session_type: 'Practice' }),
|
||||||
|
session({ session_name: 'Sprint', session_type: 'Sprint' }),
|
||||||
|
session({ session_type: 'Race' }),
|
||||||
|
]
|
||||||
|
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
|
||||||
|
expect(isSprintWeekend(sprint)).toBe(true)
|
||||||
|
expect(isSprintWeekend(normal)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts podium and pole', () => {
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 100,
|
||||||
|
gap_to_leader: 0,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 2,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 101,
|
||||||
|
gap_to_leader: 1,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 18,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 16,
|
||||||
|
position: 3,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'e8002d',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 102,
|
||||||
|
gap_to_leader: 2,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 15,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const grid: EnrichedGrid[] = [
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
lap_duration: 70,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const podium = extractPodium(results)
|
||||||
|
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
|
||||||
|
expect(extractPole(grid)?.name_acronym).toBe('NOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds title fight gaps for top 3', () => {
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2026,
|
||||||
|
round: 5,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 19,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1'],
|
||||||
|
drivers: [
|
||||||
|
hubDriver({ points: 200, position: 1 }),
|
||||||
|
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||||
|
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = buildTitleFightContext(hub)
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].gapToLeader).toBe(0)
|
||||||
|
expect(rows[1].gapToLeader).toBe(40)
|
||||||
|
expect(rows[1].gapAfterRaceWin).toBe(15)
|
||||||
|
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
|
||||||
|
expect(RACE_WIN_POINTS).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats points gaps', () => {
|
||||||
|
expect(formatPointsGap(0)).toBe('LEADER')
|
||||||
|
expect(formatPointsGap(40)).toBe('+40')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks countdown target as next future session', () => {
|
||||||
|
const sessions = [
|
||||||
|
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
|
||||||
|
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
const now = new Date('2026-05-23T12:00:00Z')
|
||||||
|
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ export interface Meeting {
|
|||||||
country_name: string
|
country_name: string
|
||||||
country_code: string
|
country_code: string
|
||||||
country_flag: string
|
country_flag: string
|
||||||
|
circuit_key?: number
|
||||||
circuit_short_name: string
|
circuit_short_name: string
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -23,6 +24,7 @@ export interface Session {
|
|||||||
session_key: number
|
session_key: number
|
||||||
session_name: string
|
session_name: string
|
||||||
session_type: string
|
session_type: string
|
||||||
|
circuit_key?: number
|
||||||
meeting_key: number
|
meeting_key: number
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
|
|||||||
Reference in New Issue
Block a user