mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-10-06 18:51:56 -04:00
Compare commits
4 Commits
v0.4.0
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e6b6af35 | ||
|
|
21b29bec6a | ||
|
|
fbae59c431 | ||
|
|
3397352835 |
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user