mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-10-06 18:51:56 -04:00
Compare commits
4 Commits
3a377d0bc4
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2393842fb6 | ||
|
|
21b29bec6a | ||
|
|
fbae59c431 | ||
|
|
3397352835 |
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0d0d0d" />
|
||||
<meta name="description" content="box-box: A premium Live F1 Dashboard and Timing Tower" />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,9 @@ const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Nav />
|
||||
<Outlet />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1434,7 +1434,7 @@ a { color: inherit; text-decoration: none; }
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.dl-page-title {
|
||||
@@ -1444,7 +1444,7 @@ a { color: inherit; text-decoration: none; }
|
||||
|
||||
.dl-page-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.dl-page-banner {
|
||||
@@ -1470,7 +1470,7 @@ a { color: inherit; text-decoration: none; }
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s5);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
color: var(--text-2);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1554,7 +1554,7 @@ a { color: inherit; text-decoration: none; }
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-3);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.dl-stat-val {
|
||||
@@ -1592,7 +1592,7 @@ a { color: inherit; text-decoration: none; }
|
||||
|
||||
.dl-content-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
color: var(--text-2);
|
||||
font-family: var(--f-mono);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
|
||||
import { DataLibraryPage } from '../pages/DataLibraryPage'
|
||||
import type { DatasetInfo, Meeting, Weekend } from '../types'
|
||||
|
||||
@@ -75,14 +77,16 @@ function renderPage() {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataLibraryPage />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => null,
|
||||
component: DataLibraryPage,
|
||||
})
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
|
||||
@@ -103,6 +107,9 @@ describe('DataLibraryPage', () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByTestId('data-library-empty')).toBeInTheDocument()
|
||||
expect(screen.getByRole('main')).toContainElement(screen.getByTestId('data-library-empty'))
|
||||
expect(screen.getByText('box-box · admin')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Local SQLite domain store/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/No ingested seasons yet/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('box-box --ingest-year 2025')).toBeInTheDocument()
|
||||
})
|
||||
@@ -115,10 +122,15 @@ describe('DataLibraryPage', () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
|
||||
expect(screen.getByRole('main')).toContainElement(screen.getByTestId('data-library'))
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
|
||||
})
|
||||
|
||||
expect(screen.getByText('box-box · admin')).toBeInTheDocument()
|
||||
const statLabels = [...document.querySelectorAll('.dl-stat-label')].map((el) => el.textContent)
|
||||
expect(statLabels).toEqual(expect.arrayContaining(['Full', 'Partial', 'Cancelled', 'Missing', 'Total']))
|
||||
expect(document.querySelector('.dl-content-meta')).toHaveTextContent(/1 meeting/)
|
||||
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Monaco').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('11/11')).toBeInTheDocument()
|
||||
@@ -126,6 +138,17 @@ describe('DataLibraryPage', () => {
|
||||
expect(screen.getByText('box-box --ingest-session 9472')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not disable pinch-zoom in the viewport meta', () => {
|
||||
const html = readFileSync(resolve(__dirname, '../../index.html'), 'utf8')
|
||||
const match = html.match(/<meta name="viewport" content="([^"]+)"/)
|
||||
expect(match).not.toBeNull()
|
||||
const content = match![1]
|
||||
expect(content).toMatch(/width\s*=\s*device-width/)
|
||||
expect(content).toMatch(/initial-scale\s*=\s*1/)
|
||||
expect(content).not.toMatch(/user-scalable\s*=\s*no/i)
|
||||
expect(content).not.toMatch(/maximum-scale/i)
|
||||
})
|
||||
|
||||
it('shows partial badge for partial weekends', async () => {
|
||||
const partialWeekend: Weekend = {
|
||||
...weekend,
|
||||
|
||||
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