mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Merge pull request #94 from AmanTahiliani/feat/frontend-design-uxpass
Live page design pass: give the timing tower the fold back
This commit is contained in:
@@ -53,9 +53,8 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
||||
|
||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
const isRace = isRaceSession(sessionType)
|
||||
// In practice/qualifying deg trends are secondary — collapse by default so
|
||||
// the Timing Tower stays above the fold. Races keep it open.
|
||||
const [collapsed, setCollapsed] = useState(!isRace)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [readerChose, setReaderChose] = useState(false)
|
||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||
|
||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||
@@ -75,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
[rows, pinned],
|
||||
)
|
||||
|
||||
// One linear fit per driver per snapshot, shared by the readiness check and
|
||||
// the rows below. degradationModel is O(laps) and this runs at feed rate.
|
||||
const models = useMemo(() => {
|
||||
const out: Record<string, ReturnType<typeof degradationModel>> = {}
|
||||
for (const row of visible) {
|
||||
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||
}
|
||||
return out
|
||||
}, [visible, stints])
|
||||
|
||||
// Before any stint has enough clean laps to fit, every row reads "warming
|
||||
// up" — a full-height panel of placeholders that pushed the Timing Tower off
|
||||
// the fold for the first third of a race. Stay collapsed until there is
|
||||
// something to say, then open. A reader who has toggled it keeps their choice.
|
||||
const hasSignal = useMemo(
|
||||
() => visible.some((row) => models[row.RacingNumber] != null),
|
||||
[visible, models],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (readerChose) return
|
||||
setCollapsed(!(isRace && hasSignal))
|
||||
}, [isRace, hasSignal, readerChose])
|
||||
|
||||
if (visible.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -82,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="sec-header tyredeg-toggle"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
onClick={() => {
|
||||
setReaderChose(true)
|
||||
setCollapsed((prev) => !prev)
|
||||
}}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<span className="sec-title">Tyre Deg & Pit Window</span>
|
||||
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
||||
{!hasSignal ? (
|
||||
<span className="sec-meta">collecting clean laps</span>
|
||||
) : (
|
||||
isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>
|
||||
)}
|
||||
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="tyredeg-rows">
|
||||
{visible.map((row) => {
|
||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||
const model = models[row.RacingNumber]
|
||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||
return (
|
||||
|
||||
@@ -289,14 +289,16 @@ export function LiveTimingPage() {
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
</div>
|
||||
{/* Rail runs most-synthesized to most-raw: a reader arriving
|
||||
mid-session wants "what did I miss" before the regulatory log. */}
|
||||
<div className="live-rc-col">
|
||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||
<TeamRadioTicker
|
||||
captures={snapshot.TeamRadio ?? []}
|
||||
driverInfo={snapshot.DriverInfo}
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.app-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
|
||||
where the links stop fitting, fade the trailing edge so the cut-off item
|
||||
reads as "scroll for more" instead of as a clipping bug. */
|
||||
@media (max-width: 560px) {
|
||||
.app-nav {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 15px;
|
||||
|
||||
@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
||||
}
|
||||
|
||||
describe('TyreDegPanel', () => {
|
||||
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
||||
it('stays collapsed while every stint is still warming up', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
|
||||
// A panel of "warming up" placeholders carries no information and used to
|
||||
// push the Timing Tower off the fold for the first third of a race.
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
expect(panel).toHaveTextContent('collecting clean laps')
|
||||
})
|
||||
|
||||
it('shows a warming-up placeholder on each row once expanded', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
expect(panel).toHaveTextContent('VER')
|
||||
expect(panel).toHaveTextContent('M +5')
|
||||
expect(panel).toHaveTextContent('fresh')
|
||||
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
|
||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||
]
|
||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -92,16 +104,44 @@ describe('TyreDegPanel', () => {
|
||||
expect(panel).not.toHaveTextContent('~P')
|
||||
})
|
||||
|
||||
it('starts expanded during a race', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
it('opens itself during a race as soon as a stint has signal', () => {
|
||||
const { rerender } = render(
|
||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||
)
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
|
||||
for (let lap = 2; lap <= 6; lap++) {
|
||||
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||
}
|
||||
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the reader\'s own collapse choice when signal arrives', () => {
|
||||
const { rerender } = render(
|
||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||
)
|
||||
// Reader opens it early, then closes it again — that decision must stick
|
||||
// even once the panel would otherwise auto-open.
|
||||
const toggle = screen.getByRole('button', { name: /tyre deg/i })
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(toggle)
|
||||
|
||||
for (let lap = 2; lap <= 6; lap++) {
|
||||
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||
}
|
||||
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('limits rows to the top ten plus pinned drivers', () => {
|
||||
const rows = Array.from({ length: 15 }, (_, index) =>
|
||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||
)
|
||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user