From 85a8b6ad44319db6a1f15d6aed2781721a75253e Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 02:47:04 -0400 Subject: [PATCH] feat: redesign Live Timing page with dynamic animations and premium dashboard layout --- frontend/package-lock.json | 7 ++ frontend/package.json | 1 + .../src/components/live/RaceControlFeed.tsx | 117 +++++++++++++++--- frontend/src/components/live/TimingTower.tsx | 4 +- frontend/src/pages/LiveTimingPage.tsx | 2 +- frontend/src/styles/app.css | 117 +++++++++++++++++- 6 files changed, 225 insertions(+), 23 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d2eba33..3a31589 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "box-box-web", "version": "0.1.0", "dependencies": { + "@formkit/auto-animate": "^0.9.0", "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.81.0", "lucide-react": "^1.23.0", @@ -903,6 +904,12 @@ "node": ">=18" } }, + "node_modules/@formkit/auto-animate": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz", + "integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index e4a4552..f930f49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "test:watch": "vitest" }, "dependencies": { + "@formkit/auto-animate": "^0.9.0", "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.81.0", "lucide-react": "^1.23.0", diff --git a/frontend/src/components/live/RaceControlFeed.tsx b/frontend/src/components/live/RaceControlFeed.tsx index b667821..4b40eb7 100644 --- a/frontend/src/components/live/RaceControlFeed.tsx +++ b/frontend/src/components/live/RaceControlFeed.tsx @@ -1,36 +1,119 @@ -import type { LiveRCMessage } from '../../types' +import { useEffect, useRef, useState } from 'react' +import type { LiveRCMessage, LiveDriverInfo } from '../../types' import { latestRaceControl, rcFlagClass } from '../../lib/live' +import { teamColor } from '../../utils' +import { useAutoAnimate } from '@formkit/auto-animate/react' interface Props { messages: LiveRCMessage[] + driverInfo?: Record } -export function RaceControlFeed({ messages }: Props) { +function FormattedMessage({ text, driverInfo }: { text: string, driverInfo?: Record }) { + if (!text) return null; + const regex = /(CAR \d+(?: \([A-Z]{3}\))?|DELETED|TRACK LIMITS|INVESTIGATING|NO FURTHER INVESTIGATION|TIME PENALTY|DRIVE THROUGH PENALTY|STOP AND GO PENALTY|BLACK AND WHITE FLAG|DISQUALIFIED)/g; + const parts = text.split(regex); + + return ( + + {parts.map((part, i) => { + if (!part) return null; + if (part.startsWith('CAR ')) { + let style = {} + const match = part.match(/CAR (\d+)/) + if (match && driverInfo && driverInfo[match[1]]) { + const hex = teamColor(driverInfo[match[1]].TeamColour) + style = { + backgroundColor: hex, + color: '#fff', + textShadow: '0 1px 2px rgba(0,0,0,0.5)', + border: 'none' + } + } + return {part} + } + if (part === 'DELETED' || part.includes('PENALTY') || part === 'DISQUALIFIED') return {part}; + if (part === 'TRACK LIMITS' || part === 'INVESTIGATING' || part === 'BLACK AND WHITE FLAG') return {part}; + if (part === 'NO FURTHER INVESTIGATION') return {part}; + return {part}; + })} + + ) +} + +export function RaceControlFeed({ messages, driverInfo }: Props) { const latest = latestRaceControl(messages) + const [listRef] = useAutoAnimate() + const prevMessagesRef = useRef([]) + const [newKeys, setNewKeys] = useState>(new Set()) + + useEffect(() => { + const prev = prevMessagesRef.current + if (prev.length > 0 && messages.length > prev.length) { + // Find new messages + const newlyAdded = messages.filter( + (m) => !prev.some((p) => p.Time === m.Time && p.Message === m.Message) + ) + + if (newlyAdded.length > 0) { + const keys = newlyAdded.map((m) => `${m.Time}-${m.Message}`) + setNewKeys((current) => { + const updated = new Set(current) + keys.forEach(k => updated.add(k)) + return updated + }) + + // Clear highlight after 3 seconds + setTimeout(() => { + setNewKeys((current) => { + const updated = new Set(current) + keys.forEach(k => updated.delete(k)) + return updated + }) + }, 3000) + } + } + prevMessagesRef.current = messages + }, [messages]) return ( -
-
+
+
Race Control {messages.length > 0 && {messages.length} messages}
{latest.length === 0 ? (
No race control messages in the current live snapshot.
) : ( -
- {latest.map((message, index) => ( -
- {message.Time || '--:--'} - {message.Lap > 0 && L{message.Lap}} - {message.Flag - ? {message.Flag} - : message.Category && message.Category !== 'Other' - ? {message.Category} - : null +
+ {latest.map((message, index) => { + const key = `${message.Time}-${message.Message}` + const isNew = newKeys.has(key) + let flashClass = '' + if (isNew) { + if (message.Flag === 'YELLOW' || message.Flag === 'DOUBLE YELLOW') { + flashClass = ' rc-item-yellow' + } else if (message.Flag === 'RED') { + flashClass = ' rc-item-red' + } else { + flashClass = ' rc-item-new' } - {message.Message} -
- ))} + } + + return ( +
+ {message.Time || '--:--'} + {message.Lap > 0 && L{message.Lap}} + {message.Flag + ? {message.Flag} + : message.Category && message.Category !== 'Other' + ? {message.Category} + : null + } + +
+ ) + })}
)}
diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx index eee5c54..8c6732d 100644 --- a/frontend/src/components/live/TimingTower.tsx +++ b/frontend/src/components/live/TimingTower.tsx @@ -1,5 +1,6 @@ import { useState, Fragment } from 'react' import { teamColor } from '../../utils' +import { useAutoAnimate } from '@formkit/auto-animate/react' import type { LiveStintData } from '../../types' import type { LiveTimingRow } from '../../lib/live' import { @@ -42,6 +43,7 @@ export function TimingTower({ }: Props) { const [expandedRow, setExpandedRow] = useState(null) const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval') + const [listRef] = useAutoAnimate({ duration: 300, easing: 'ease-out' }) if (rows.length === 0) { return ( @@ -85,7 +87,7 @@ export function TimingTower({ - + {rows.map((row) => { const driver = row.Driver const delta = positionDelta(driver) diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 1f84f5d..1c0ca07 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -164,7 +164,7 @@ export function LiveTimingPage() { />
- +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 49dfe71..7274c48 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -665,10 +665,119 @@ a { color: inherit; text-decoration: none; } .track-red { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.25); } .track-vsc { background: rgba(194,120,255,.12); color: var(--purple); border: 1px solid rgba(194,120,255,.25); } -.live-tower { font-variant-numeric: tabular-nums; } -.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; } +.live-tower { + font-variant-numeric: tabular-nums; + border-collapse: separate; + border-spacing: 0 4px; +} +.live-tower th { + border-bottom: none !important; + padding-bottom: 12px; +} +.live-tower td { + border-bottom: none !important; +} +.live-tower tbody tr { + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(8px); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} +.live-tower tbody tr:hover { + background: rgba(255, 255, 255, 0.07); + transform: scale(1.005) translateX(4px); +} +.live-tower tbody tr td:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} +.live-tower tbody tr td:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} +.live-tower .in-pit td { background: rgba(0, 80, 160, 0.2) !important; } +.live-tower .pit-out td { background: rgba(57, 199, 58, 0.15) !important; } +.live-tower .retired td { opacity: 0.5; filter: grayscale(80%); } + +/* Race Control Panel & Animations */ +.panel-glass { + background: rgba(20, 20, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 16px; + backdrop-filter: blur(12px); + max-height: calc(100vh - 120px); + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.panel-glass .sticky-header { + position: sticky; + top: -16px; + background: rgba(20, 20, 20, 0.9); + padding: 16px 0 12px 0; + margin-top: -16px; + z-index: 10; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + backdrop-filter: blur(12px); +} + +.rc-hlt { + font-weight: 700; + padding: 0 2px; + border-radius: 2px; +} +.rc-hlt-car { + color: #fff; + background: rgba(255, 255, 255, 0.15); +} +.rc-hlt-bad { + color: #ff6b6b; + background: rgba(225, 6, 0, 0.15); +} +.rc-hlt-warn { + color: var(--yellow); + background: rgba(255, 214, 0, 0.15); +} +.rc-hlt-ok { + color: var(--green); + background: rgba(57, 199, 58, 0.15); +} + +.panel-glass .sticky-header { + position: sticky; + top: -16px; + background: rgba(20, 20, 20, 0.9); + padding: 16px 0 12px 0; + margin-top: -16px; + z-index: 10; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + backdrop-filter: blur(12px); +} + +@keyframes flash-rc { + 0% { background: rgba(255, 255, 255, 0.2); transform: scale(1.02); } + 100% { background: transparent; transform: scale(1); } +} +.rc-item-new { + animation: flash-rc 2.5s ease-out forwards; +} + +@keyframes flash-rc-yellow { + 0% { background: rgba(255, 214, 0, 0.35); transform: scale(1.03); box-shadow: 0 0 15px rgba(255, 214, 0, 0.5); } + 100% { background: rgba(255, 214, 0, 0.05); transform: scale(1); box-shadow: 0 0 0 transparent; } +} +.rc-item-yellow { + animation: flash-rc-yellow 2.5s ease-out forwards; +} + +@keyframes flash-rc-red { + 0% { background: rgba(225, 6, 0, 0.4); transform: scale(1.03); box-shadow: 0 0 15px rgba(225, 6, 0, 0.6); } + 100% { background: rgba(225, 6, 0, 0.05); transform: scale(1); box-shadow: 0 0 0 transparent; } +} +.rc-item-red { + animation: flash-rc-red 2.5s ease-out forwards; +} .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); }