Compare commits

...

4 Commits

Author SHA1 Message Date
AmanTahiliani
a1e6b6af35 feat(#105): design Preview circuit-outline unavailable state
Keep the Circuit card when GPS outline is missing: show meeting identity and an honest local-cache explanation instead of a one-line empty section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:36:19 -04:00
AmanTahiliani
21b29bec6a fix(command-center): hide the season strip's native scrollbar
The strip sits inside a left/right edge mask, so the always-on macOS
scrollbar rendered as a half-faded light slab across the dark panel and
claimed an 11px gutter. Hide it: the strip already opens centred on the
current GP and the fades mark the overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:50:54 -04:00
AmanTahiliani
fbae59c431 feat(command-center): level the dashboard columns
Stretch the two dashboard columns to a shared row height so whichever side
is taller sets the baseline and the other fills down to it, instead of the
main panel stopping short of the sidebar.

Raise the paddock briefing preview to ten headlines and let the strip take
the column's leftover height, so the extra space is filled with news rather
than empty panel; anything past the fold scrolls in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:56:01 -04:00
AmanTahiliani
3397352835 feat(command-center): balance columns and open the calendar on the current GP
Move the paddock briefing under the season calendar in the main column so
the sidebar (championship + data note) stops running longer than the left
side of the dashboard.

Open the season strip scrolled to the round in focus rather than at round
one, so the relevant GP is centred on load. Re-centring repeats while the
strip reflows (font load, resize) and stops as soon as the reader scrolls
it themselves; the edge fade only applies when the strip actually overflows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:48:15 -04:00
10 changed files with 265 additions and 21 deletions

View File

@@ -29,6 +29,8 @@ const SOURCE_DISPLAY: Record<string, string> = {
'f1-youtube': 'F1 YouTube',
}
const PREVIEW_LIMIT = 10
export function PaddockBriefing() {
const now = useMemo(() => new Date(), [])
@@ -76,7 +78,9 @@ export function PaddockBriefing() {
const sinceLabel = sinceLastLabel(meetings, now)
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
// The strip flexes to the bottom of the dashboard column, so offer more than
// fits and let the overflow scroll rather than leaving the column short.
const preview = sinceItems.length > 0 ? sinceItems.slice(0, PREVIEW_LIMIT) : tagged.slice(0, PREVIEW_LIMIT)
return (
<section className="cc-briefing" data-testid="paddock-briefing">

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
@@ -56,6 +56,9 @@ function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | und
export function CommandCenterPage() {
const [now, setNow] = useState(() => Date.now())
const calendarScrollRef = useRef<HTMLDivElement | null>(null)
const focusCardRef = useRef<HTMLAnchorElement | null>(null)
const railTakenOver = useRef(false)
const seasonsQuery = useQuery({
queryKey: ['seasons'],
@@ -198,6 +201,56 @@ export function CommandCenterPage() {
? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.`
: 'The weekend timetable is ready to explore.'
// Park the calendar rail on the GP in focus so it lands centred, instead of
// making the reader scroll past every round that has already happened.
const focusMeetingKey = focusMeeting?.meeting_key
useEffect(() => {
railTakenOver.current = false
}, [focusMeetingKey])
useEffect(() => {
const rail = calendarScrollRef.current
const card = focusCardRef.current
if (!rail || !card) return
const sync = () => {
const overflows = rail.scrollWidth > rail.clientWidth + 1
rail.classList.toggle('is-scrollable', overflows)
if (!overflows || railTakenOver.current) return
const railBox = rail.getBoundingClientRect()
const cardBox = card.getBoundingClientRect()
const offset = rail.scrollLeft + (cardBox.left - railBox.left)
const target = offset - (rail.clientWidth - cardBox.width) / 2
rail.scrollLeft = Math.max(0, Math.min(target, rail.scrollWidth - rail.clientWidth))
}
sync()
const takeOver = () => {
railTakenOver.current = true
}
rail.addEventListener('wheel', takeOver, { passive: true })
rail.addEventListener('touchstart', takeOver, { passive: true })
rail.addEventListener('pointerdown', takeOver)
rail.addEventListener('keydown', takeOver)
// The strip reflows as fonts load and as the column width changes, so keep
// re-centring until the reader takes the rail over themselves.
let observer: ResizeObserver | undefined
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(sync)
observer.observe(rail)
}
return () => {
observer?.disconnect()
rail.removeEventListener('wheel', takeOver)
rail.removeEventListener('touchstart', takeOver)
rail.removeEventListener('pointerdown', takeOver)
rail.removeEventListener('keydown', takeOver)
}
}, [focusMeetingKey, seasonMeetings.length])
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center…</div>
}
@@ -317,7 +370,7 @@ export function CommandCenterPage() {
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
</span>
</div>
<div className="cc-calendar-grid" role="list">
<div className="cc-calendar-grid" role="list" ref={calendarScrollRef} data-testid="cc-calendar-rail">
{seasonMeetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const target = pickAnalysisSession(weekend)
@@ -327,6 +380,7 @@ export function CommandCenterPage() {
return (
<Link
key={meeting.meeting_key}
ref={status === 'focus' ? focusCardRef : undefined}
to="/race-hub"
search={targetKey ? { session_key: targetKey } : {}}
className={`cc-calendar-card cc-calendar-${status}`}
@@ -359,6 +413,7 @@ export function CommandCenterPage() {
)}
</section>
)}
<PaddockBriefing />
</div>
</div>
@@ -456,7 +511,6 @@ export function CommandCenterPage() {
</section>
)}
<PaddockBriefing />
<div className="cc-data-note mono" data-testid="cc-data-note">
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session

View File

@@ -58,35 +58,67 @@ function SectionState({
return <>{children}</>
}
function circuitIdentity(meeting: Meeting): { name: string; place: string } {
const name = meeting.circuit_short_name?.trim() || meeting.meeting_name
const location = meeting.location?.trim() ?? ''
const country = meeting.country_name?.trim() ?? ''
const parts: string[] = []
if (location) parts.push(location)
if (country && country !== location) parts.push(country)
return { name, place: parts.join(' · ') }
}
function TrackOutlineCard({
meeting,
outline,
loading,
error,
accent,
}: {
meeting: Meeting
outline: TrackOutline | null | undefined
loading: boolean
error: Error | null
accent: string
}) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const identity = circuitIdentity(meeting)
let body: ReactNode
if (loading) {
body = <div className="preview-section-state">Loading…</div>
} else if (error) {
body = (
<div className="preview-section-state error">
{error instanceof Error ? error.message : 'Failed to load'}
</div>
)
} else if (outlinePath) {
body = (
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
)
} else {
body = (
<div className="preview-track-fallback" data-testid="preview-track-fallback">
<div className="preview-track-fallback-glyph" aria-hidden />
<p className="preview-track-fallback-name">{identity.name}</p>
{identity.place && <p className="preview-track-fallback-place">{identity.place}</p>}
<p className="preview-track-fallback-hint">
A GPS track outline is not available in the local cache.
</p>
</div>
)
}
return (
<section className="preview-card" data-testid="preview-track-card">
<h2 className="preview-card-title">Circuit</h2>
<SectionState
loading={loading}
error={error}
empty={!outline || !outlinePath}
emptyMessage="Track outline unavailable for this circuit"
>
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
</SectionState>
{body}
</section>
)
}
@@ -408,6 +440,7 @@ export function RacePreviewPage() {
<div className="preview-grid">
<TrackOutlineCard
meeting={previewMeeting}
outline={trackOutlineQuery.data}
loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}

View File

@@ -1865,13 +1865,22 @@ a { color: inherit; text-decoration: none; }
display: grid;
grid-template-columns: minmax(0, 65fr) minmax(0, 35fr);
gap: var(--s6);
align-items: start;
/* Stretch, not start: whichever column is taller sets the row height and the
other fills down to it, so the two never end on different baselines. */
align-items: stretch;
}
.cc-dashboard-panel {
flex: 1;
min-height: 0;
}
}
.cc-dashboard-main {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.cc-dashboard-panel {
@@ -3357,7 +3366,15 @@ a { color: inherit; text-decoration: none; }
/* ── Command Center — Paddock Briefing preview strip ── */
.cc-briefing {
margin-top: var(--s7);
display: flex;
flex-direction: column;
gap: var(--s3);
flex: 1;
min-height: 0;
}
.cc-briefing .sec-header {
margin-bottom: 0;
}
.briefing-state {
@@ -3396,6 +3413,14 @@ a { color: inherit; text-decoration: none; }
display: flex;
flex-direction: column;
gap: 1px;
/* Takes the column's leftover height; extra headlines scroll instead of
pushing the panel past the sidebar. */
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
}
.cc-brief-item {
@@ -4835,7 +4860,23 @@ a { color: inherit; text-decoration: none; }
gap: 0;
padding: var(--s3) 0 var(--s4);
overflow-x: auto;
scrollbar-width: thin;
overscroll-behavior-x: contain;
/* No native scrollbar: it sits inside the edge mask below, so it renders as a
half-faded light slab on the dark panel. The strip opens centred on the
current GP and the fades mark the overflow, so nothing is lost by hiding it. */
scrollbar-width: none;
-ms-overflow-style: none;
}
.cc-calendar-grid::-webkit-scrollbar {
display: none;
}
/* The strip opens centred on the GP in focus, so rounds run off both edges —
fade them out rather than slicing a card in half. */
.cc-calendar-grid.is-scrollable {
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
}
.cc-calendar-card {

View File

@@ -156,7 +156,6 @@
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s3);
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);

View File

@@ -203,6 +203,47 @@
stroke-linejoin: round;
}
.preview-track-fallback {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--s2);
min-height: 180px;
padding: var(--s5);
}
.preview-track-fallback-glyph {
width: 88px;
height: 52px;
margin-bottom: var(--s2);
border: 2px dashed rgba(255, 255, 255, 0.14);
border-radius: 42% 50% 46% 54%;
}
.preview-track-fallback-name {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-1);
margin: 0;
}
.preview-track-fallback-place {
font-size: 0.85rem;
color: var(--text-2);
margin: 0;
}
.preview-track-fallback-hint {
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-3);
max-width: 22rem;
margin: var(--s2) 0 0;
}
.preview-podium {
display: flex;
flex-direction: column;

View File

@@ -223,6 +223,8 @@ describe('RacePreviewPage', () => {
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Circuit outline' })).toBeInTheDocument()
expect(screen.queryByTestId('preview-track-fallback')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
@@ -273,5 +275,75 @@ describe('RacePreviewPage', () => {
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
const fallback = screen.getByTestId('preview-track-fallback')
expect(screen.getByTestId('preview-track-card')).toContainElement(fallback)
expect(fallback).toHaveTextContent('Monaco')
expect(fallback).toHaveTextContent('A GPS track outline is not available in the local cache.')
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
})
it('shows a designed circuit fallback when the track outline is unavailable', async () => {
const italianGp: Meeting = {
...upcomingMeeting,
meeting_name: 'Italian Grand Prix',
meeting_official_name: 'Formula 1 Pirelli Gran Premio d\'Italia',
location: 'Monza',
country_name: 'Italy',
country_code: 'ITA',
circuit_short_name: 'Monza',
}
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [italianGp]
if (year === 2098) return [{ ...italianGp, meeting_key: 90, year: 2098 }]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
mockFetchTrackOutline.mockResolvedValue(null)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-track-fallback')).toBeInTheDocument()
})
const card = screen.getByTestId('preview-track-card')
const fallback = screen.getByTestId('preview-track-fallback')
expect(card).toContainElement(fallback)
expect(card).toHaveTextContent('Circuit')
expect(fallback).toHaveTextContent('Monza')
expect(fallback).toHaveTextContent('Italy')
expect(fallback).toHaveTextContent('A GPS track outline is not available in the local cache.')
expect(fallback).not.toHaveTextContent(/unknown/i)
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
expect(card).not.toHaveTextContent('Track outline unavailable for this circuit')
})
it('keeps a thrown outline request distinct from the designed unavailable state', async () => {
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [upcomingMeeting]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
mockFetchTrackOutline.mockRejectedValue(new Error('outline request failed'))
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-track-card')).toHaveTextContent('outline request failed')
})
expect(screen.queryByTestId('preview-track-fallback')).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 160 KiB