diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ecdc40c..00ac55b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -26,6 +26,15 @@ export async function fetchLocalMeetings(year: number): Promise { return Array.isArray(meetings) ? meetings : [] } +export async function fetchSeasonMeetings(year: number): Promise { + const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + const meetings = await res.json() + return Array.isArray(meetings) ? meetings : [] +} + export async function fetchWeekend(meetingKey: number): Promise { const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`) if (!res.ok) { diff --git a/frontend/src/lib/gpIdentity.ts b/frontend/src/lib/gpIdentity.ts index 0683d01..24cca81 100644 --- a/frontend/src/lib/gpIdentity.ts +++ b/frontend/src/lib/gpIdentity.ts @@ -11,6 +11,7 @@ const COUNTRY_ACCENTS: Record = { DEU: '#cdc7c2', BEL: '#e0a01b', NLD: '#ef7c1a', + NED: '#ef7c1a', HUN: '#3f8a4b', AUT: '#c61f1f', AZE: '#1f7eaf', @@ -20,8 +21,11 @@ const COUNTRY_ACCENTS: Record = { SGP: '#c8202c', BRA: '#2fa84f', BHR: '#a8132b', + BRN: '#a8132b', SAU: '#0a7a3b', + KSA: '#0a7a3b', ARE: '#4d6b2a', + UAE: '#4d6b2a', QAT: '#7a1e3e', CHN: '#c8202c', MEX: '#1f7a4d', @@ -43,6 +47,46 @@ export function countryDecal(meeting: Meeting | undefined | null): string { return name.slice(0, 3).toUpperCase() || '—' } +export function countryFlag(meeting: Meeting | undefined | null): string { + if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag + const code = meeting?.country_code?.toUpperCase() + const iso2 = code ? CODE3_TO_2[code] : '' + if (!iso2) return '' + const offset = 0x1f1e6 - 65 + return String.fromCodePoint(iso2.charCodeAt(0) + offset, iso2.charCodeAt(1) + offset) +} + +const CODE3_TO_2: Record = { + AUS: 'AU', + AUT: 'AT', + AZE: 'AZ', + BEL: 'BE', + BHR: 'BH', + BRN: 'BH', + BRA: 'BR', + CAN: 'CA', + CHN: 'CN', + DEU: 'DE', + GER: 'DE', + ESP: 'ES', + FRA: 'FR', + GBR: 'GB', + HUN: 'HU', + ITA: 'IT', + JPN: 'JP', + MEX: 'MX', + MON: 'MC', + NED: 'NL', + NLD: 'NL', + QAT: 'QA', + SAU: 'SA', + KSA: 'SA', + SGP: 'SG', + UAE: 'AE', + ARE: 'AE', + USA: 'US', +} + export function formatGpDateRange(meeting: Meeting | undefined | null): string { if (!meeting) return '' const start = meeting.date_start?.slice(0, 10) diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx index b3c2b64..17019eb 100644 --- a/frontend/src/pages/CommandCenterPage.tsx +++ b/frontend/src/pages/CommandCenterPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useQueries, useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { fetchLiveState, fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api' +import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchWeekend } from '../api' import { countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' import { currentAndNextSession, @@ -14,7 +14,7 @@ import { sessionStartTime, sortSessionsByStart, } from '../lib/schedule' -import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity' +import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' import type { Meeting, Session, Weekend, WeekendSession } from '../types' import { PaddockBriefing } from '../components/PaddockBriefing' @@ -28,21 +28,10 @@ function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | ' return 'upcoming' } -function collectRecentWeekends(weekends: (Weekend | undefined)[], focusKey: number | null, limit = 6) { - const rows: Weekend[] = [] - for (const weekend of weekends) { - if (!weekend) continue - if (focusKey != null && weekend.meeting_key === focusKey) continue - if (weekend.source === 'none') continue - rows.push(weekend) - } - return rows - .sort((a, b) => { - const left = Date.parse(a.meeting.date_start ?? '') - const right = Date.parse(b.meeting.date_start ?? '') - return right - left - }) - .slice(0, limit) +function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { + if (meeting.meeting_key === focusKey) return 'focus' + if (meetingHasStarted(meeting, now)) return 'past' + return 'future' } function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined { @@ -73,7 +62,15 @@ export function CommandCenterPage() { enabled: latestSeason != null, }) - const meetings = meetingsQuery.data ?? [] + const seasonMeetingsQuery = useQuery({ + queryKey: ['season-meetings', latestSeason], + queryFn: () => fetchSeasonMeetings(latestSeason!), + enabled: latestSeason != null, + }) + + const localMeetings = meetingsQuery.data ?? [] + const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings + const meetings = localMeetings const weekendQueries = useQueries({ queries: meetings.map((meeting) => ({ @@ -116,7 +113,6 @@ export function CommandCenterPage() { : [] const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate) - const recentWeekends = collectRecentWeekends(weekendList, focusMeeting?.meeting_key ?? null) const analysisSession = pickAnalysisSession(focusWeekend) const analysisSessionKey = analysisSession?.session.session_key ?? @@ -314,45 +310,51 @@ export function CommandCenterPage() { )} - {(weekendsLoading || recentWeekends.length > 0) && ( -
+ {seasonMeetings.length > 0 && ( +
- Recent Local Weekends - {recentWeekends.length} + Season Calendar + + {seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local +
- {weekendsLoading && recentWeekends.length === 0 ? ( -
loading weekends…
- ) : recentWeekends.length === 0 ? ( -
No additional local weekends.
- ) : ( -
- {recentWeekends.map((weekend) => { - const finished = meetingHasStarted(weekend.meeting, nowDate) - const target = pickAnalysisSession(weekend) - const target_key = target?.session.session_key ?? weekend.default_session_key - return ( - -
- {countryDecal(weekend.meeting)} -
-
{weekend.meeting.meeting_name}
-
- {formatGpDateRange(weekend.meeting)} - {' · '} - {finished ? 'Past' : 'Upcoming'} -
- - ) - })} -
+
+ {seasonMeetings.map((meeting, index) => { + const weekend = weekendsByKey.get(meeting.meeting_key) + const target = pickAnalysisSession(weekend) + const targetKey = target?.session.session_key ?? weekend?.default_session_key + const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate) + const cardAccent = countryAccent(meeting) + return ( + + + {seasonMeetingsQuery.isError && ( +
Using local meetings because the full calendar could not load.
)}
)} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index ae96c50..e47ee33 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1296,7 +1296,7 @@ a { color: inherit; text-decoration: none; } .cc-band-accent { width: 6px; - background: var(--gp-accent); + background: linear-gradient(180deg, var(--gp-accent), rgba(255,255,255,0.28), var(--gp-accent)); flex-shrink: 0; } @@ -1304,9 +1304,25 @@ a { color: inherit; text-decoration: none; } flex: 1; padding: var(--s5); min-width: 0; + position: relative; + overflow: hidden; + background: + linear-gradient(135deg, color-mix(in srgb, var(--gp-accent) 18%, transparent), transparent 54%), + var(--surface); +} + +.cc-band-body::after { + content: ""; + position: absolute; + inset: 0; + background: + repeating-linear-gradient(110deg, transparent 0 22px, rgba(255,255,255,0.025) 22px 23px); + pointer-events: none; } .cc-band-row { + position: relative; + z-index: 1; display: flex; align-items: stretch; gap: var(--s6); @@ -1551,6 +1567,133 @@ a { color: inherit; text-decoration: none; } .cc-cov-none { background: var(--text-3); opacity: 0.5; } /* Recent weekends strip */ +.cc-season-calendar { display: flex; flex-direction: column; gap: var(--s3); } + +.cc-calendar-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); + gap: var(--s3); +} + +.cc-calendar-card { + --gp-card-accent: var(--red); + position: relative; + min-height: 148px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: var(--s4); + padding: var(--s4); + overflow: hidden; + background: + linear-gradient(145deg, color-mix(in srgb, var(--gp-card-accent) 22%, transparent), transparent 58%), + var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + transition: transform 0.12s, border-color 0.12s, background 0.12s; +} + +.cc-calendar-card::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 82% 18%, color-mix(in srgb, var(--gp-card-accent) 26%, transparent), transparent 32%), + repeating-linear-gradient(115deg, transparent 0 18px, rgba(255,255,255,0.025) 18px 19px); + pointer-events: none; +} + +.cc-calendar-card:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--gp-card-accent) 58%, var(--border)); + background: + linear-gradient(145deg, color-mix(in srgb, var(--gp-card-accent) 28%, transparent), transparent 60%), + var(--surface-h); +} + +.cc-calendar-card > * { + position: relative; + z-index: 1; +} + +.cc-calendar-accent { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--gp-card-accent); +} + +.cc-calendar-focus { + border-color: color-mix(in srgb, var(--gp-card-accent) 66%, var(--border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gp-card-accent) 22%, transparent); +} + +.cc-calendar-past { + opacity: 0.58; +} + +.cc-calendar-top, +.cc-calendar-id { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); +} + +.cc-calendar-round { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + color: var(--text-3); +} + +.cc-calendar-decal { + font-size: 28px; + font-weight: 900; + letter-spacing: -0.04em; + line-height: 0.95; + color: var(--gp-card-accent); + filter: brightness(1.25); +} + +.cc-calendar-flag { + font-size: 18px; + line-height: 1; +} + +.cc-calendar-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.cc-calendar-name { + font-size: 12px; + font-weight: 700; + color: var(--text); + line-height: 1.25; +} + +.cc-calendar-circuit, +.cc-calendar-date { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cc-calendar-circuit { + font-size: 10px; + color: var(--text-2); +} + +.cc-calendar-date { + font-size: 10px; + color: var(--text-3); +} + .cc-recent { display: flex; flex-direction: column; gap: var(--s3); } .cc-recent-strip { diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx index dbe02bb..559a75a 100644 --- a/frontend/src/test/CommandCenterPage.test.tsx +++ b/frontend/src/test/CommandCenterPage.test.tsx @@ -8,14 +8,16 @@ import type { DatasetInfo, Meeting, Weekend } from '../types' vi.mock('../api', () => ({ fetchSeasons: vi.fn(), fetchLocalMeetings: vi.fn(), + fetchSeasonMeetings: vi.fn(), fetchWeekend: vi.fn(), fetchLiveState: vi.fn(), })) -import { fetchSeasons, fetchLocalMeetings, fetchWeekend, fetchLiveState } from '../api' +import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchWeekend, fetchLiveState } from '../api' const mockFetchSeasons = vi.mocked(fetchSeasons) const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings) +const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings) const mockFetchWeekend = vi.mocked(fetchWeekend) const mockFetchLiveState = vi.mocked(fetchLiveState) @@ -113,6 +115,7 @@ describe('CommandCenterPage', () => { it('shows weekend identity band and schedule when data exists', async () => { mockFetchSeasons.mockResolvedValue([2025]) mockFetchLocalMeetings.mockResolvedValue([meeting]) + mockFetchSeasonMeetings.mockResolvedValue([meeting]) mockFetchWeekend.mockResolvedValue(weekend) renderPage() @@ -126,6 +129,8 @@ describe('CommandCenterPage', () => { }) expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON') + expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar') + expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01') expect(screen.getByText('No live session')).toBeInTheDocument() expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race') })