Add Paddock Briefing UI (Phase 21)

This commit is contained in:
2026-05-25 13:04:29 -04:00
parent 3bd169c55c
commit 7ce2b1ace4
6 changed files with 177 additions and 1 deletions

View File

@@ -1,4 +1,4 @@
import type { LiveStateResponse, Meeting, RaceHub, Weekend } from './types'
import type { LiveStateResponse, Meeting, NewsItem, RaceHub, Weekend } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
@@ -41,3 +41,18 @@ export async function fetchLiveState(): Promise<LiveStateResponse> {
}
return res.json()
}
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString())
if (source) params.set('source', source)
const query = params.toString()
const url = query ? `/api/v1/news?${query}` : '/api/v1/news'
const res = await fetch(url)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

@@ -0,0 +1,61 @@
import { useQuery } from '@tanstack/react-query'
import { fetchNews } from '../api'
import { timeAgo } from '../utils'
export function PaddockBriefing() {
const { data: news, isLoading, isError } = useQuery({
queryKey: ['news'],
queryFn: () => fetchNews(12),
staleTime: 60_000,
})
if (isLoading) {
return <div className="briefing-state loading-state">loading paddock briefing</div>
}
if (isError || !news) {
return <div className="briefing-state error-box">Failed to load paddock briefing</div>
}
if (news.length === 0) {
return <div className="briefing-state empty-box">No briefing items available.</div>
}
return (
<section className="cc-briefing" data-testid="paddock-briefing">
<div className="sec-header">
<span className="sec-title">Paddock Briefing</span>
<span className="sec-meta mono">Latest intel</span>
</div>
<div className="briefing-grid" role="list">
{news.map((item, i) => {
const age = timeAgo(item.published_at || item.fetched_at)
return (
<a
key={`${item.url}-${i}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="briefing-card"
role="listitem"
>
<div className="briefing-card-head mono">
<span className="briefing-source">{item.source}</span>
<span className="briefing-age">{age}</span>
</div>
<h3 className="briefing-title">{item.title}</h3>
{item.summary && (
<p className="briefing-summary">
{item.summary.length > 120 ? item.summary.substring(0, 117) + '...' : item.summary}
</p>
)}
{item.category && (
<div className="briefing-cat mono">{item.category.toLowerCase()}</div>
)}
</a>
)
})}
</div>
</section>
)
}

View File

@@ -16,6 +16,7 @@ import {
} from '../lib/schedule'
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
import { PaddockBriefing } from '../components/PaddockBriefing'
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
@@ -355,6 +356,8 @@ export function CommandCenterPage() {
)}
</section>
)}
<PaddockBriefing />
</div>
)
}

View File

@@ -2297,3 +2297,76 @@ a { color: inherit; text-decoration: none; }
.cc-empty-actions { grid-template-columns: 1fr; }
.cc-empty-title { font-size: 19px; }
}
/* ── Paddock Briefing ── */
.cc-briefing {
margin-top: var(--s7);
}
.briefing-state {
margin-top: var(--s7);
}
.briefing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--s4);
}
.briefing-card {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
padding: var(--s4);
border-radius: 2px;
transition: border-color 0.1s, background 0.1s;
}
.briefing-card:hover {
background: var(--surface-h);
border-color: var(--border-2);
}
.briefing-card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 10px;
color: var(--text-3);
margin-bottom: var(--s3);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.briefing-source {
color: var(--text-2);
font-weight: 700;
}
.briefing-title {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
margin-bottom: var(--s3);
color: var(--text);
}
.briefing-summary {
font-size: 12px;
color: var(--text-2);
line-height: 1.5;
margin-bottom: var(--s4);
flex-grow: 1;
}
.briefing-cat {
font-size: 9px;
color: var(--text-3);
align-self: flex-start;
padding: 1px 4px;
border: 1px solid var(--border);
border-radius: 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
}

View File

@@ -266,3 +266,13 @@ export interface LiveStreamData {
ClockExtrapolating: boolean
Stints: Record<string, LiveStintData[]>
}
export interface NewsItem {
source: string
title: string
url: string
published_at?: string
summary?: string
category?: string
fetched_at: string
}

View File

@@ -73,3 +73,17 @@ export function finishPositionOrder(pos: number): number {
export function compareFinishPosition(a: number, b: number): number {
return finishPositionOrder(a) - finishPositionOrder(b)
}
export function timeAgo(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const seconds = Math.floor((Date.now() - d.getTime()) / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}