From 9532206522f7a09a4a550335eefb2edc1c44aa1b Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 10:45:35 -0400 Subject: [PATCH] Polish live timing UI --- .../24-phase-16-live-timing-polish.md | 43 +++++++ documentations/refactor/README.md | 2 + .../src/components/live/RaceControlFeed.tsx | 13 ++- .../src/components/live/SessionBanner.tsx | 43 ++++--- frontend/src/components/live/TimingTower.tsx | 38 +++++-- frontend/src/lib/live.ts | 23 ++++ frontend/src/pages/LiveTimingPage.tsx | 31 +++-- frontend/src/styles/app.css | 106 ++++++++++++++++-- frontend/src/test/live.test.ts | 12 ++ 9 files changed, 269 insertions(+), 42 deletions(-) create mode 100644 documentations/refactor/24-phase-16-live-timing-polish.md diff --git a/documentations/refactor/24-phase-16-live-timing-polish.md b/documentations/refactor/24-phase-16-live-timing-polish.md new file mode 100644 index 0000000..d3ac5a9 --- /dev/null +++ b/documentations/refactor/24-phase-16-live-timing-polish.md @@ -0,0 +1,43 @@ +# Phase 16: Live Timing Polish + +## Goal + +Improve the React Live Timing route as an operations screen while preserving the +existing official F1 SignalR bridge and TUI live behavior. + +## Completed Scope + +- Added pure helpers for position delta styling and race-control flag classes. +- Improved the timing tower with podium position styling, colored position + deltas, best-lap/lap-count columns, and compact status badges. +- Reworked the session banner so track status, lap count, clock, live/stale + state, and weather read as dense operational metadata. +- Improved race-control feed treatment with color-coded flag badges, category + labels for non-flag messages, and bounded scrolling. +- Reworked the live route layout into a two-column desktop view with timing + tower priority and race control alongside it. +- Improved empty and disconnected states without requiring a real live F1 + session. + +## Constraints + +- No backend live bridge or TUI live code was changed. +- No persisted live storage was added. +- Tests continue to use disabled-live/empty-state coverage because an active F1 + session is not guaranteed. + +## Verification + +```bash +npm --prefix frontend test -- --run +npm --prefix frontend run build +npm run test:e2e +npm run test:e2e:prod +npm run test:visual +npm run test:visual:prod +``` + +## Related + +- [21 MVP Completion Checklist](21-mvp-completion-checklist.md) +- [23 Phase 15 Command Center](23-phase-15-command-center.md) diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index a12a162..d1acbf2 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -81,6 +81,8 @@ not implementation tickets yet. screenshot coverage for MVP routes and responsive viewports. - [23 Phase 15 Command Center](23-phase-15-command-center.md): default Web entry screen for local coverage, weekend focus, live status, and next actions. +- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser + React live timing layout, status treatment, and race-control polish. ## External References diff --git a/frontend/src/components/live/RaceControlFeed.tsx b/frontend/src/components/live/RaceControlFeed.tsx index da270ed..b667821 100644 --- a/frontend/src/components/live/RaceControlFeed.tsx +++ b/frontend/src/components/live/RaceControlFeed.tsx @@ -1,5 +1,5 @@ import type { LiveRCMessage } from '../../types' -import { latestRaceControl } from '../../lib/live' +import { latestRaceControl, rcFlagClass } from '../../lib/live' interface Props { messages: LiveRCMessage[] @@ -17,13 +17,18 @@ export function RaceControlFeed({ messages }: Props) { {latest.length === 0 ? (
No race control messages in the current live snapshot.
) : ( -
+
{latest.map((message, index) => (
{message.Time || '--:--'} - {message.Flag && {message.Flag}} {message.Lap > 0 && L{message.Lap}} - {message.Message} + {message.Flag + ? {message.Flag} + : message.Category && message.Category !== 'Other' + ? {message.Category} + : null + } + {message.Message}
))}
diff --git a/frontend/src/components/live/SessionBanner.tsx b/frontend/src/components/live/SessionBanner.tsx index 2f32078..559644c 100644 --- a/frontend/src/components/live/SessionBanner.tsx +++ b/frontend/src/components/live/SessionBanner.tsx @@ -12,26 +12,39 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) { const session = snapshot.Session const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now) const status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : '' + const weather = snapshot.Weather + const hasWeather = weather && (weather.AirTemp > 0 || weather.TrackTemp > 0) return (
-
- {connection} -
-

{session?.MeetingName || 'Live Timing'}

-

- {[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'} -

+
+
+ {connection} +
+

{session?.MeetingName || 'Live Timing'}

+

+ {[session?.SessionName, session?.CircuitName].filter(Boolean).join(' · ') || 'F1 live feed'} +

+
+
+
+ {status && {status}} + + L{snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} + + {clock && {clock}} + {isLive ? 'live' : 'stale'}
-
- - Lap {snapshot.CurrentLap || '-'}/{snapshot.TotalLaps || '-'} - - {status && {status}} - {clock && {clock}} - {isLive ? 'live' : 'stale'} -
+ {hasWeather && ( +
+ {weather.AirTemp.toFixed(0)}° air + {weather.TrackTemp.toFixed(0)}° track + {weather.Humidity > 0 && {weather.Humidity.toFixed(0)}% humidity} + {weather.WindSpeed > 0 && {weather.WindSpeed.toFixed(1)} m/s} + {weather.Rainfall && WET} +
+ )}
) } diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx index 274a91c..164203c 100644 --- a/frontend/src/components/live/TimingTower.tsx +++ b/frontend/src/components/live/TimingTower.tsx @@ -1,11 +1,25 @@ import { teamColor } from '../../utils' import type { LiveStreamData } from '../../types' -import { driverCode, positionDelta, sortLiveTimingRows, tyreClass, tyreLabel } from '../../lib/live' +import { + driverCode, + positionDelta, + positionDeltaClass, + sortLiveTimingRows, + tyreClass, + tyreLabel, +} from '../../lib/live' interface Props { snapshot: LiveStreamData } +function posClass(pos: number): string { + if (pos === 1) return 'pos-p1' + if (pos === 2) return 'pos-p2' + if (pos === 3) return 'pos-p3' + return 'pos-n' +} + export function TimingTower({ snapshot }: Props) { const rows = sortLiveTimingRows(snapshot) @@ -19,7 +33,7 @@ export function TimingTower({ snapshot }: Props) { return (
- +
@@ -28,12 +42,15 @@ export function TimingTower({ snapshot }: Props) { - + + {rows.map((row) => { const driver = row.Driver + const delta = positionDelta(driver) + const deltaClass = positionDeltaClass(driver) return ( - - + + - + + ) })} diff --git a/frontend/src/lib/live.ts b/frontend/src/lib/live.ts index c0e8f36..42bb193 100644 --- a/frontend/src/lib/live.ts +++ b/frontend/src/lib/live.ts @@ -99,6 +99,29 @@ export function positionDelta(driver: LiveDriverData): string { return driver.PrevPosition > driver.Position ? '▲' : '▼' } +export function positionDeltaClass(driver: LiveDriverData): string { + if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return '' + return driver.PrevPosition > driver.Position ? 'pos-gain' : 'pos-loss' +} + +const RC_FLAG_CSS: Record = { + GREEN: 'rc-flag-green', + YELLOW: 'rc-flag-yellow', + 'DOUBLE YELLOW': 'rc-flag-yellow', + RED: 'rc-flag-red', + SC: 'rc-flag-sc', + 'SAFETY CAR': 'rc-flag-sc', + VSC: 'rc-flag-vsc', + 'VIRTUAL SAFETY CAR': 'rc-flag-vsc', + CHEQUERED: 'rc-flag-chequered', + CHECKERED: 'rc-flag-chequered', +} + +export function rcFlagClass(flag: string): string { + if (!flag) return '' + return RC_FLAG_CSS[flag.toUpperCase()] ?? '' +} + export function tyreLabel(tyre: LiveTyreData | undefined): string { if (!tyre) return '?' const compound = tyre.Compound?.charAt(0) || '?' diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index f258337..d0c17e5 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -76,29 +76,42 @@ export function LiveTimingPage() { )} - {streamStatus === 'disconnected' && ( -
Live stream disconnected. Showing the last received snapshot.
+ {streamStatus === 'disconnected' && snapshot && ( +
+ Stream disconnected — showing last received snapshot +
)} - {isLoading && !snapshot &&
connecting to live timing…
} + {isLoading && !snapshot && ( +
connecting to live timing…
+ )} {!isLoading && !snapshot && (
+
+ {streamStatus} +
No live session active
-
Check back during an F1 race weekend.
+
+ No timing data in the current snapshot. The feed will update automatically when an F1 session goes live. +
)} {snapshot && ( <> -
-
- Timing Tower +
+
+
+ Timing Tower +
+ +
+
+
-
- )}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index f106129..5398f87 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -481,13 +481,16 @@ a { color: inherit; text-decoration: none; } .live-page { max-width: 1120px; } .live-banner { + padding-bottom: var(--s4); + border-bottom: 1px solid var(--border); + margin-bottom: var(--s5); +} + +.live-banner-row { display: flex; justify-content: space-between; align-items: center; gap: var(--s5); - padding-bottom: var(--s4); - border-bottom: 1px solid var(--border); - margin-bottom: var(--s5); } .live-banner-main { @@ -515,6 +518,63 @@ a { color: inherit; text-decoration: none; } flex-wrap: wrap; } +.live-weather-strip { + display: flex; + gap: var(--s4); + align-items: center; + flex-wrap: wrap; + margin-top: var(--s3); + padding-top: var(--s3); + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-3); + font-family: var(--f-mono); +} + +.badge-wet { + background: rgba(0,128,255,.14); + color: #66aaff; + border: 1px solid rgba(0,128,255,.26); +} + +/* ── Live columns layout ── */ +.live-columns { + display: flex; + flex-direction: column; + gap: var(--s5); +} + +@media (min-width: 900px) { + .live-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: var(--s5); + align-items: start; + } +} + +/* ── Live status strip ── */ +.live-status-strip { + padding: var(--s2) var(--s5); + font-size: 11px; + font-family: var(--f-mono); + margin-bottom: var(--s4); + border-radius: 2px; +} + +.live-status-warn { + background: rgba(255,214,0,.06); + border: 1px solid rgba(255,214,0,.2); + color: var(--yellow); +} + +/* ── Live empty state ── */ +.live-empty-status { + display: flex; + justify-content: center; + margin-bottom: var(--s4); +} + .mono { font-family: var(--f-mono); } .live-conn, @@ -549,11 +609,16 @@ a { color: inherit; text-decoration: none; } .live-tower .in-pit td { background: rgba(0, 80, 160, 0.14); } .live-tower .pit-out td { background: rgba(57, 199, 58, 0.10); } .live-tower .retired td { opacity: 0.62; } -.pos-delta { color: var(--text-3); font-family: var(--f-mono); } +.pos-delta { font-family: var(--f-mono); color: var(--text-3); } +.pos-delta.pos-gain { color: var(--green); } +.pos-delta.pos-loss { color: var(--red); } .lap-pb { color: var(--green); } .lap-ob { color: var(--purple); } -.badge-pit { background: rgba(0,128,255,.14); color: #66aaff; border: 1px solid rgba(0,128,255,.26); } -.badge-out { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } +.badge-pit { background: rgba(0,128,255,.14); color: #66aaff; border: 1px solid rgba(0,128,255,.26); } +.badge-out { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } +.badge-flying { background: rgba(194,120,255,.14); color: var(--purple); border: 1px solid rgba(194,120,255,.26); } +.badge-knocked { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.24); } +.badge-cutoff { background: rgba(255,214,0,.12); color: var(--yellow); border: 1px solid rgba(255,214,0,.25); } .tyre-soft { background: var(--tyre-soft); color: #fff; } .tyre-medium { background: var(--tyre-medium); color: #111; } @@ -568,6 +633,11 @@ a { color: inherit; text-decoration: none; } border-top: 1px solid var(--border); } +.live-rc-scroll { + max-height: 320px; + overflow-y: auto; +} + .live-rc-row { display: flex; align-items: baseline; @@ -575,6 +645,7 @@ a { color: inherit; text-decoration: none; } padding: var(--s3) 0; border-bottom: 1px solid var(--border); font-size: 12px; + flex-wrap: wrap; } .rc-time, @@ -585,6 +656,8 @@ a { color: inherit; text-decoration: none; } font-size: 11px; } +.rc-message { flex: 1; min-width: 0; } + .rc-flag { flex-shrink: 0; padding: 1px 5px; @@ -596,6 +669,23 @@ a { color: inherit; text-decoration: none; } letter-spacing: 0.06em; } +.rc-flag-green { background: rgba(57,199,58,.15); color: var(--green); border-color: rgba(57,199,58,.3); } +.rc-flag-yellow { background: rgba(255,214,0,.15); color: var(--yellow); border-color: rgba(255,214,0,.3); } +.rc-flag-red { background: rgba(225,6,0,.15); color: #ff6b6b; border-color: rgba(225,6,0,.3); } +.rc-flag-sc { background: rgba(255,214,0,.15); color: var(--yellow); border-color: rgba(255,214,0,.3); } +.rc-flag-vsc { background: rgba(194,120,255,.15); color: var(--purple); border-color: rgba(194,120,255,.3); } +.rc-flag-chequered { background: rgba(200,200,200,.10); color: var(--text-2); border-color: var(--border-2); } + +.rc-category { + flex-shrink: 0; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-3); + font-family: var(--f-mono); +} + .missing-notice { padding: var(--s4) var(--s5); background: var(--surface); @@ -1240,7 +1330,7 @@ a { color: inherit; text-decoration: none; } .session-bar input { flex: 1; max-width: 140px; } - .live-banner { + .live-banner-row { flex-direction: column; align-items: flex-start; gap: var(--s3); @@ -1250,6 +1340,8 @@ a { color: inherit; text-decoration: none; } width: 100%; } .live-banner h1 { font-size: 16px; } + .live-weather-strip { gap: var(--s3); } + .live-rc-scroll { max-height: 220px; } .dataset-strip { flex-wrap: nowrap; diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts index 3899b9f..78c7a52 100644 --- a/frontend/src/test/live.test.ts +++ b/frontend/src/test/live.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { extrapolateClock, latestRaceControl, + positionDeltaClass, parseLiveStateEvent, + rcFlagClass, sortLiveTimingRows, trackStatusLabel, tyreClass, @@ -121,6 +123,16 @@ describe('live transforms', () => { expect(latestRaceControl(snapshot.RCMessages, 1)[0].Message).toBe('DRS ENABLED') }) + it('maps position delta and race-control flag classes', () => { + expect(positionDeltaClass(snapshot.Drivers['16'])).toBe('pos-gain') + expect(positionDeltaClass(snapshot.Drivers['1'])).toBe('pos-loss') + expect(positionDeltaClass({ ...snapshot.Drivers['1'], PrevPosition: 2, Position: 2 })).toBe('') + expect(rcFlagClass('GREEN')).toBe('rc-flag-green') + expect(rcFlagClass('safety car')).toBe('rc-flag-sc') + expect(rcFlagClass('virtual safety car')).toBe('rc-flag-vsc') + expect(rcFlagClass('unknown')).toBe('') + }) + it('extrapolates the session clock from the reference time', () => { expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30') })
PosTyre Last Lap GapBestBestLaps
{row.Position}{positionDelta(driver)}{row.Position}{delta}
{driverCode(row)} {row.RacingNumber} {driver.InPit && PIT} - {driver.Retired && OUT} + {driver.PitOut && !driver.InPit && OUT} + {driver.Retired && RET} + {driver.KnockedOut && KO} + {driver.Cutoff && !driver.KnockedOut && CUT} + {driver.OnFlyingLap && FL}
@@ -61,7 +82,10 @@ export function TimingTower({ snapshot }: Props) { {driver.LastLapTime || '-'} {driver.GapToLeader || driver.Interval || '-'}{driver.BestLapTime || '-'} + {driver.BestLapTime || '-'} + {driver.NumberOfLaps || '-'}