mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add season calendar to command center
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user