From 7ce2b1ace4f30a54b9b39f49b282154bb7f72049 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 13:04:29 -0400 Subject: [PATCH] Add Paddock Briefing UI (Phase 21) --- frontend/src/api.ts | 17 ++++- frontend/src/components/PaddockBriefing.tsx | 61 +++++++++++++++++ frontend/src/pages/CommandCenterPage.tsx | 3 + frontend/src/styles/app.css | 73 +++++++++++++++++++++ frontend/src/types.ts | 10 +++ frontend/src/utils.ts | 14 ++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/PaddockBriefing.tsx diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6a7d879..a62466a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) @@ -41,3 +41,18 @@ export async function fetchLiveState(): Promise { } return res.json() } + +export async function fetchNews(limit?: number, source?: string): Promise { + 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() +} diff --git a/frontend/src/components/PaddockBriefing.tsx b/frontend/src/components/PaddockBriefing.tsx new file mode 100644 index 0000000..22a772d --- /dev/null +++ b/frontend/src/components/PaddockBriefing.tsx @@ -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
loading paddock briefing…
+ } + + if (isError || !news) { + return
Failed to load paddock briefing
+ } + + if (news.length === 0) { + return
No briefing items available.
+ } + + return ( +
+
+ Paddock Briefing + Latest intel +
+ +
+ ) +} diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx index e789127..b3c2b64 100644 --- a/frontend/src/pages/CommandCenterPage.tsx +++ b/frontend/src/pages/CommandCenterPage.tsx @@ -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() { )} )} + + ) } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 02f4039..48312bf 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 575206b..cae761a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -266,3 +266,13 @@ export interface LiveStreamData { ClockExtrapolating: boolean Stints: Record } + +export interface NewsItem { + source: string + title: string + url: string + published_at?: string + summary?: string + category?: string + fetched_at: string +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 3ee3aaa..3d7b0a3 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -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` +}