Add season calendar to command center

This commit is contained in:
2026-05-25 18:14:55 -04:00
parent a2aa772838
commit 024accfb3d
5 changed files with 261 additions and 58 deletions

View File

@@ -26,6 +26,15 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
return Array.isArray(meetings) ? meetings : []
}
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
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<Weekend> {
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
if (!res.ok) {

View File

@@ -11,6 +11,7 @@ const COUNTRY_ACCENTS: Record<string, string> = {
DEU: '#cdc7c2',
BEL: '#e0a01b',
NLD: '#ef7c1a',
NED: '#ef7c1a',
HUN: '#3f8a4b',
AUT: '#c61f1f',
AZE: '#1f7eaf',
@@ -20,8 +21,11 @@ const COUNTRY_ACCENTS: Record<string, string> = {
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<string, string> = {
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)

View File

@@ -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() {
</section>
)}
{(weekendsLoading || recentWeekends.length > 0) && (
<section className="cc-recent">
{seasonMeetings.length > 0 && (
<section className="cc-season-calendar" data-testid="cc-season-calendar">
<div className="sec-header">
<span className="sec-title">Recent Local Weekends</span>
<span className="sec-meta mono">{recentWeekends.length}</span>
<span className="sec-title">Season Calendar</span>
<span className="sec-meta mono">
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
</span>
</div>
{weekendsLoading && recentWeekends.length === 0 ? (
<div className="loading-state">loading weekends</div>
) : recentWeekends.length === 0 ? (
<div className="cc-side-empty">No additional local weekends.</div>
) : (
<div className="cc-recent-strip" role="list">
{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 (
<Link
key={weekend.meeting_key}
to="/race-hub"
search={target_key ? { session_key: target_key } : {}}
className="cc-recent-card"
data-testid={`cc-weekend-${weekend.meeting_key}`}
role="listitem"
>
<div className="cc-recent-head mono">
<span className="cc-recent-decal">{countryDecal(weekend.meeting)}</span>
<span className={`cc-cov-dot cc-cov-${weekend.source}`} aria-hidden="true" />
</div>
<div className="cc-recent-name">{weekend.meeting.meeting_name}</div>
<div className="cc-recent-meta mono">
{formatGpDateRange(weekend.meeting)}
{' · '}
{finished ? 'Past' : 'Upcoming'}
</div>
</Link>
)
})}
</div>
<div className="cc-calendar-grid" role="list">
{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 (
<Link
key={meeting.meeting_key}
to="/race-hub"
search={targetKey ? { session_key: targetKey } : {}}
className={`cc-calendar-card cc-calendar-${status}`}
data-testid={`cc-calendar-${meeting.meeting_key}`}
role="listitem"
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
>
<div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
</div>
<div className="cc-calendar-id">
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
</div>
<div className="cc-calendar-copy">
<div className="cc-calendar-name">{meeting.meeting_name}</div>
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
</div>
</Link>
)
})}
</div>
{seasonMeetingsQuery.isError && (
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
)}
</section>
)}

View File

@@ -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 {

View File

@@ -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')
})