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>
This commit is contained in:
2026-08-08 12:48:15 -04:00
parent 3a377d0bc4
commit 3397352835
6 changed files with 72 additions and 5 deletions

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

@@ -3357,7 +3357,13 @@ 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);
}
.cc-briefing .sec-header {
margin-bottom: 0;
}
.briefing-state {
@@ -4835,9 +4841,17 @@ a { color: inherit; text-decoration: none; }
gap: 0;
padding: var(--s3) 0 var(--s4);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
/* 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 {
min-width: 112px;
min-height: 138px;

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);

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