Add race control visuals and driver names in laps view

This commit is contained in:
2026-05-25 15:55:36 -04:00
parent 3eb74a9083
commit a2aa772838
21 changed files with 458 additions and 118 deletions

View File

@@ -1,8 +1,9 @@
import type { Lap } from '../types' import type { Driver, Lap } from '../types'
import { formatLapTime } from '../utils' import { formatGap, formatLapTime, teamColor } from '../utils'
interface Props { interface Props {
laps: Lap[] laps: Lap[]
drivers?: Driver[]
} }
interface DriverLapSummary { interface DriverLapSummary {
@@ -13,7 +14,7 @@ interface DriverLapSummary {
pitOuts: number pitOuts: number
} }
export function LapsView({ laps }: Props) { export function LapsView({ laps, drivers = [] }: Props) {
if (laps.length === 0) { if (laps.length === 0) {
return ( return (
<div className="missing-notice"> <div className="missing-notice">
@@ -58,45 +59,52 @@ export function LapsView({ laps }: Props) {
return a.driver_number - b.driver_number return a.driver_number - b.driver_number
}) })
const driversByNumber = new Map(drivers.map((driver) => [driver.driver_number, driver]))
const fastest = rows.find((row) => row.best?.lap_duration != null)?.best const fastest = rows.find((row) => row.best?.lap_duration != null)?.best
const fastestTime = fastest?.lap_duration ?? null
return ( return (
<div className="scroll-x" data-testid="laps-view"> <div className="scroll-x" data-testid="laps-view">
<table className="data-table" style={{ minWidth: 460, maxWidth: 620 }}> <table className="data-table" style={{ minWidth: 540, maxWidth: 700 }}>
<thead> <thead>
<tr> <tr>
<th>Driver</th> <th>Driver</th>
<th className="c">Best Lap</th> <th className="c">Best Lap</th>
<th className="r">Best Time</th> <th className="r">Best Time</th>
<th className="r">Gap</th>
<th className="r hide-mobile">Laps</th> <th className="r hide-mobile">Laps</th>
<th className="r hide-mobile">Pit Outs</th> <th className="r hide-mobile">Pit Outs</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => { {rows.map((row) => {
const driver = driversByNumber.get(row.driver_number)
const driverName =
driver?.full_name || driver?.broadcast_name || driver?.name_acronym || `#${row.driver_number}`
const colour = teamColor(driver?.team_colour)
const isFastest = const isFastest =
fastest && fastest &&
row.best?.driver_number === fastest.driver_number && row.best?.driver_number === fastest.driver_number &&
row.best?.lap_number === fastest.lap_number row.best?.lap_number === fastest.lap_number
const gap =
row.best?.lap_duration != null && fastestTime != null
? row.best.lap_duration - fastestTime
: null
return ( return (
<tr key={row.driver_number}> <tr key={row.driver_number} className={isFastest ? 'lap-fastest-row' : undefined}>
<td className="mono" style={{ fontWeight: 700 }}> <td style={{ fontWeight: 700 }}>
#{row.driver_number} <span className="drv-cell">
<span className="drv-bar" style={{ background: colour }} />
<span>{driverName}</span>
<span className="drv-num">{row.driver_number}</span>
</span>
</td> </td>
<td className="c mono"> <td className="c mono">
{row.best ? ( {row.best ? row.best.lap_number : '—'}
<>
{row.best.lap_number}
{isFastest && (
<span style={{ color: 'var(--red)', marginLeft: 6 }}>FASTEST</span>
)}
</>
) : (
'—'
)}
</td> </td>
<td className="r">{formatLapTime(row.best?.lap_duration)}</td> <td className="r">{formatLapTime(row.best?.lap_duration)}</td>
<td className="r">{isFastest ? '—' : formatGap(gap)}</td>
<td className="r hide-mobile">{row.lastLap || row.total}</td> <td className="r hide-mobile">{row.lastLap || row.total}</td>
<td className="r hide-mobile" style={{ color: 'var(--text-3)' }}> <td className="r hide-mobile" style={{ color: 'var(--text-3)' }}>
{row.pitOuts || '—'} {row.pitOuts || '—'}

View File

@@ -1,4 +1,5 @@
import type { RaceControlMessage } from '../types' import type { RaceControlMessage } from '../types'
import { rcFlagClass } from '../lib/live'
interface Props { interface Props {
messages: RaceControlMessage[] messages: RaceControlMessage[]
@@ -19,6 +20,24 @@ function eventLabel(message: RaceControlMessage): string {
return message.flag || message.category || 'Message' return message.flag || message.category || 'Message'
} }
function eventClass(message: RaceControlMessage): string {
const flagClass = rcFlagClass(message.flag ?? '')
if (flagClass) return flagClass
const category = (message.category ?? '').toLowerCase()
const text = `${message.message ?? ''} ${message.category ?? ''}`.toLowerCase()
if (category.includes('safety') || text.includes('safety car')) return 'rc-flag-sc'
if (category === 'drs' || text.includes('drs')) return 'rc-flag-drs'
if (text.includes('virtual safety car')) return 'rc-flag-vsc'
if (text.includes('red flag')) return 'rc-flag-red'
if (text.includes('yellow')) return 'rc-flag-yellow'
if (text.includes('green light') || text.includes('green flag')) return 'rc-flag-green'
if (text.includes('chequered') || text.includes('checkered')) return 'rc-flag-chequered'
return 'rc-flag-other'
}
export function RaceControlView({ messages }: Props) { export function RaceControlView({ messages }: Props) {
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
@@ -44,24 +63,27 @@ export function RaceControlView({ messages }: Props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((message, index) => ( {rows.map((message, index) => {
<tr key={`${message.date}-${index}`}> const visualClass = eventClass(message)
<td className="mono" style={{ color: 'var(--text-3)' }}> return (
{formatEventTime(message.date)} <tr className={`race-control-row ${visualClass}`} key={`${message.date}-${index}`}>
</td> <td className="mono rc-time-cell">
<td className="c mono">{message.lap_number ?? '—'}</td> {formatEventTime(message.date)}
<td> </td>
<span style={{ fontWeight: 700 }}>{eventLabel(message)}</span> <td className="c mono">{message.lap_number ?? '—'}</td>
{message.scope && ( <td>
<span style={{ color: 'var(--text-3)', marginLeft: 6 }}> <span className={`rc-event-pill rc-flag ${visualClass}`}>{eventLabel(message)}</span>
{message.scope.toLowerCase()} {message.scope && (
</span> <span className="rc-scope">
)} {message.scope.toLowerCase()}
</td> </span>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td> )}
<td style={{ whiteSpace: 'normal' }}>{message.message || '—'}</td> </td>
</tr> <td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
))} <td className="rc-message-cell">{message.message || '—'}</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -109,6 +109,10 @@ const RC_FLAG_CSS: Record<string, string> = {
YELLOW: 'rc-flag-yellow', YELLOW: 'rc-flag-yellow',
'DOUBLE YELLOW': 'rc-flag-yellow', 'DOUBLE YELLOW': 'rc-flag-yellow',
RED: 'rc-flag-red', RED: 'rc-flag-red',
BLUE: 'rc-flag-blue',
BLACK: 'rc-flag-black',
'BLACK AND ORANGE': 'rc-flag-black',
'BLACK AND WHITE': 'rc-flag-black',
SC: 'rc-flag-sc', SC: 'rc-flag-sc',
'SAFETY CAR': 'rc-flag-sc', 'SAFETY CAR': 'rc-flag-sc',
VSC: 'rc-flag-vsc', VSC: 'rc-flag-vsc',

View File

@@ -315,7 +315,7 @@ export function RaceHubPage({ sessionKey }: Props) {
<span className="sec-meta mono">{data.laps.length} samples</span> <span className="sec-meta mono">{data.laps.length} samples</span>
)} )}
</div> </div>
<LapsView laps={data.laps} /> <LapsView laps={data.laps} drivers={data.drivers} />
</div> </div>
)} )}

View File

@@ -421,6 +421,10 @@ a { color: inherit; text-decoration: none; }
} }
.data-table tbody tr:last-child td { border-bottom: none; } .data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover { background: var(--surface-h); } .data-table tbody tr:hover { background: var(--surface-h); }
.data-table tbody tr.lap-fastest-row td,
.data-table tbody tr.lap-fastest-row .drv-num {
color: var(--purple);
}
.data-table td.r { text-align: right; font-family: var(--f-mono); } .data-table td.r { text-align: right; font-family: var(--f-mono); }
.data-table td.c { text-align: center; } .data-table td.c { text-align: center; }
@@ -705,6 +709,71 @@ a { color: inherit; text-decoration: none; }
.rc-flag-sc { background: rgba(255,214,0,.15); color: var(--yellow); border-color: rgba(255,214,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-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-flag-chequered { background: rgba(200,200,200,.10); color: var(--text-2); border-color: var(--border-2); }
.rc-flag-blue { background: rgba(67,156,255,.15); color: #66aaff; border-color: rgba(67,156,255,.3); }
.rc-flag-black { background: rgba(10,10,10,.55); color: var(--text-2); border-color: rgba(255,255,255,.18); }
.rc-flag-drs { background: rgba(0,212,255,.13); color: #00d4ff; border-color: rgba(0,212,255,.28); }
.rc-flag-other { background: var(--surface-2); color: var(--text-2); border-color: var(--border-2); }
.race-control-row {
position: relative;
}
.race-control-row td:first-child {
border-left: 3px solid transparent;
}
.race-control-row.rc-flag-green td:first-child { border-left-color: var(--green); }
.race-control-row.rc-flag-yellow td:first-child,
.race-control-row.rc-flag-sc td:first-child { border-left-color: var(--yellow); }
.race-control-row.rc-flag-red td:first-child { border-left-color: var(--red); }
.race-control-row.rc-flag-vsc td:first-child { border-left-color: var(--purple); }
.race-control-row.rc-flag-blue td:first-child { border-left-color: #66aaff; }
.race-control-row.rc-flag-drs td:first-child { border-left-color: #00d4ff; }
.race-control-row.rc-flag-chequered td:first-child { border-left-color: var(--text-2); }
.race-control-row.rc-flag-yellow td,
.race-control-row.rc-flag-sc td {
background: rgba(255,214,0,.035);
}
.race-control-row.rc-flag-red td {
background: rgba(225,6,0,.045);
}
.race-control-row.rc-flag-green td {
background: rgba(57,199,58,.03);
}
.rc-time-cell {
color: var(--text-3);
}
.rc-event-pill {
align-items: center;
display: inline-flex;
gap: 5px;
min-width: 0;
max-width: 100%;
vertical-align: middle;
}
.rc-event-pill::before {
content: "";
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
flex: 0 0 auto;
}
.rc-scope {
color: var(--text-3);
margin-left: 6px;
}
.rc-message-cell {
white-space: normal;
}
.rc-category { .rc-category {
flex-shrink: 0; flex-shrink: 0;

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { LapsView } from '../components/LapsView' import { LapsView } from '../components/LapsView'
import type { Lap } from '../types' import type { Driver, Lap } from '../types'
const laps: Lap[] = [ const laps: Lap[] = [
{ {
@@ -33,15 +33,46 @@ const laps: Lap[] = [
}, },
] ]
const drivers: Driver[] = [
{
driver_number: 44,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
first_name: 'Lewis',
last_name: 'Hamilton',
team_name: 'Ferrari',
team_colour: 'E80020',
headshot_url: '',
broadcast_name: 'L HAMILTON',
session_key: 9472,
meeting_key: 1229,
},
{
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
first_name: 'Max',
last_name: 'Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671C6',
headshot_url: '',
broadcast_name: 'M VERSTAPPEN',
session_key: 9472,
meeting_key: 1229,
},
]
describe('LapsView', () => { describe('LapsView', () => {
it('renders compact best-lap rows by driver', () => { it('renders compact best-lap rows by driver', () => {
render(<LapsView laps={laps} />) render(<LapsView laps={laps} drivers={drivers} />)
expect(screen.getByTestId('laps-view')).toBeInTheDocument() expect(screen.getByTestId('laps-view')).toBeInTheDocument()
expect(screen.getByText('#1')).toBeInTheDocument() expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
expect(screen.getByText('#44')).toBeInTheDocument() expect(screen.getByText('Lewis Hamilton')).toBeInTheDocument()
expect(screen.getByText('1:12.100')).toBeInTheDocument() expect(screen.getByText('1:12.100')).toBeInTheDocument()
expect(screen.getByText('FASTEST')).toBeInTheDocument() expect(screen.getByText('+3.100')).toBeInTheDocument()
expect(screen.queryByText('FASTEST')).not.toBeInTheDocument()
expect(screen.getByText('Max Verstappen').closest('tr')).toHaveClass('lap-fastest-row')
}) })
it('shows a missing-data state when no laps are present', () => { it('shows a missing-data state when no laps are present', () => {

View File

@@ -273,24 +273,12 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
return 0, errors.New("no Race sessions found") return 0, errors.New("no Race sessions found")
} }
// Walk backwards to find the most recent completed race.
now := time.Now() now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- { latestKey, ok := latestCompletedRaceSessionKey(sessions, now)
s := sessions[i] if !ok {
if s.DateEnd != "" { return 0, errors.New("no completed Race sessions found")
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
}
} }
return latestKey, nil
return 0, errors.New("no completed Race sessions found")
} }
// getLatestRaceSessionKeyForYear returns the session_key of the most recent // getLatestRaceSessionKeyForYear returns the session_key of the most recent
@@ -311,25 +299,47 @@ func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
return 0, fmt.Errorf("no Race sessions found for year %d", year) return 0, fmt.Errorf("no Race sessions found for year %d", year)
} }
// Walk backwards to find the most recent completed race.
now := time.Now() now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- { latestKey, ok := latestCompletedRaceSessionKey(sessions, now)
s := sessions[i] if !ok {
if s.DateEnd != "" { return 0, fmt.Errorf("no completed Race sessions found for year %d", year)
endTime, err := time.Parse(time.RFC3339, s.DateEnd) }
if err == nil && endTime.Before(now) { return latestKey, nil
return s.SessionKey, nil }
}
} else if s.DateStart != "" { func latestCompletedRaceSessionKey(sessions []models.Session, now time.Time) (int, bool) {
// Fallback: if no DateEnd, check DateStart + 3 hours as a rough estimate. var latestKey int
startTime, err := time.Parse(time.RFC3339, s.DateStart) var latestTime time.Time
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil for _, s := range sessions {
} completedAt, ok := completedRaceTime(s)
if !ok || !completedAt.Before(now) {
continue
}
if latestKey == 0 || completedAt.After(latestTime) {
latestKey = s.SessionKey
latestTime = completedAt
} }
} }
return 0, fmt.Errorf("no completed Race sessions found for year %d", year) return latestKey, latestKey != 0
}
func completedRaceTime(s models.Session) (time.Time, bool) {
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil {
return endTime, true
}
}
if s.DateStart == "" {
return time.Time{}, false
}
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err != nil {
return time.Time{}, false
}
return startTime.Add(3 * time.Hour), true
} }
// GetLatestDriverChampionship returns championship standings for the most recent // GetLatestDriverChampionship returns championship standings for the most recent

View File

@@ -0,0 +1,60 @@
package api
import (
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
func TestLatestCompletedRaceSessionKeyUsesDatesNotInputOrder(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
sessions := []models.Session{
{
SessionKey: 1,
SessionName: "Race",
DateStart: "2025-12-01T13:00:00+00:00",
DateEnd: "2025-12-01T15:00:00+00:00",
},
{
SessionKey: 2,
SessionName: "Race",
DateStart: "2025-03-01T13:00:00+00:00",
DateEnd: "2025-03-01T15:00:00+00:00",
},
}
sessionKey, ok := latestCompletedRaceSessionKey(sessions, now)
if !ok {
t.Fatal("expected a completed race")
}
if sessionKey != 1 {
t.Fatalf("sessionKey = %d, want 1", sessionKey)
}
}
func TestLatestCompletedRaceSessionKeyIgnoresFutureSessions(t *testing.T) {
now := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
sessions := []models.Session{
{
SessionKey: 1,
SessionName: "Race",
DateStart: "2025-12-01T13:00:00+00:00",
DateEnd: "2025-12-01T15:00:00+00:00",
},
{
SessionKey: 2,
SessionName: "Race",
DateStart: "2025-05-01T13:00:00+00:00",
DateEnd: "2025-05-01T15:00:00+00:00",
},
}
sessionKey, ok := latestCompletedRaceSessionKey(sessions, now)
if !ok {
t.Fatal("expected a completed race")
}
if sessionKey != 2 {
t.Fatalf("sessionKey = %d, want 2", sessionKey)
}
}

View File

@@ -357,11 +357,17 @@ func driverToStore(d models.Driver) store.Driver {
func sessionDriverToStore(d models.Driver) store.SessionDriver { func sessionDriverToStore(d models.Driver) store.SessionDriver {
return store.SessionDriver{ return store.SessionDriver{
SessionKey: d.SessionKey, SessionKey: d.SessionKey,
DriverNumber: d.DriverNumber, DriverNumber: d.DriverNumber,
MeetingKey: d.MeetingKey, MeetingKey: d.MeetingKey,
TeamName: d.TeamName, BroadcastName: d.BroadcastName,
TeamColour: d.TeamColour, FirstName: d.FirstName,
FullName: d.FullName,
LastName: d.LastName,
NameAcronym: d.NameAcronym,
HeadshotURL: d.HeadshotURL,
TeamName: d.TeamName,
TeamColour: d.TeamColour,
} }
} }

View File

@@ -40,6 +40,30 @@ func sessionToModel(s store.Session) models.Session {
} }
func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.Driver) models.Driver { func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.Driver) models.Driver {
broadcastName := sd.BroadcastName
if broadcastName == "" {
broadcastName = d.BroadcastName
}
firstName := sd.FirstName
if firstName == "" {
firstName = d.FirstName
}
fullName := sd.FullName
if fullName == "" {
fullName = d.FullName
}
lastName := sd.LastName
if lastName == "" {
lastName = d.LastName
}
nameAcronym := sd.NameAcronym
if nameAcronym == "" {
nameAcronym = d.NameAcronym
}
headshotURL := sd.HeadshotURL
if headshotURL == "" {
headshotURL = d.HeadshotURL
}
teamName := sd.TeamName teamName := sd.TeamName
if teamName == "" { if teamName == "" {
teamName = d.TeamName teamName = d.TeamName
@@ -49,14 +73,14 @@ func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.D
teamColour = d.TeamColour teamColour = d.TeamColour
} }
return models.Driver{ return models.Driver{
BroadcastName: d.BroadcastName, BroadcastName: broadcastName,
DriverNumber: sd.DriverNumber, DriverNumber: sd.DriverNumber,
FirstName: d.FirstName, FirstName: firstName,
FullName: d.FullName, FullName: fullName,
HeadshotURL: d.HeadshotURL, HeadshotURL: headshotURL,
LastName: d.LastName, LastName: lastName,
MeetingKey: meetingKey, MeetingKey: meetingKey,
NameAcronym: d.NameAcronym, NameAcronym: nameAcronym,
SessionKey: sessionKey, SessionKey: sessionKey,
TeamColour: teamColour, TeamColour: teamColour,
TeamName: teamName, TeamName: teamName,

View File

@@ -7,13 +7,13 @@ import (
func TestCoverageCRUD(t *testing.T) { func TestCoverageCRUD(t *testing.T) {
s := openTestStore(t) s := openTestStore(t)
// Verify schema migration version is 5 (since we added 005_news_enriched.sql) // Verify all migrations are applied.
version, err := s.SchemaVersion() version, err := s.SchemaVersion()
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 5 { if version != 6 {
t.Fatalf("SchemaVersion() = %d, want 5", version) t.Fatalf("SchemaVersion() = %d, want 6", version)
} }
// Verify session_coverage table exists // Verify session_coverage table exists

View File

@@ -0,0 +1,6 @@
ALTER TABLE session_drivers ADD COLUMN broadcast_name TEXT;
ALTER TABLE session_drivers ADD COLUMN first_name TEXT;
ALTER TABLE session_drivers ADD COLUMN full_name TEXT;
ALTER TABLE session_drivers ADD COLUMN last_name TEXT;
ALTER TABLE session_drivers ADD COLUMN name_acronym TEXT;
ALTER TABLE session_drivers ADD COLUMN headshot_url TEXT;

View File

@@ -42,16 +42,16 @@ type NewsSource struct {
// NewsItem stores a normalized feed item deduplicated by URL. // NewsItem stores a normalized feed item deduplicated by URL.
type NewsItem struct { type NewsItem struct {
URL string URL string
Source string Source string
Title string Title string
PublishedAt *time.Time PublishedAt *time.Time
Summary string Summary string
Category string Category string
FetchedAt time.Time FetchedAt time.Time
OGImageURL string OGImageURL string
OGDescription string OGDescription string
ReadAt *time.Time ReadAt *time.Time
} }
// Meeting is a race weekend record. // Meeting is a race weekend record.
@@ -100,11 +100,17 @@ type Driver struct {
// SessionDriver links a driver to a session with session-specific team info. // SessionDriver links a driver to a session with session-specific team info.
type SessionDriver struct { type SessionDriver struct {
SessionKey int SessionKey int
DriverNumber int DriverNumber int
MeetingKey int MeetingKey int
TeamName string BroadcastName string
TeamColour string FirstName string
FullName string
LastName string
NameAcronym string
HeadshotURL string
TeamName string
TeamColour string
} }
// SessionResult is a final classification row for a session. // SessionResult is a final classification row for a session.

View File

@@ -88,16 +88,29 @@ func (s *Store) GetDriver(driverNumber int) (Driver, error) {
func (s *Store) UpsertSessionDriver(sd SessionDriver) error { func (s *Store) UpsertSessionDriver(sd SessionDriver) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO session_drivers ( INSERT INTO session_drivers (
session_key, driver_number, meeting_key, team_name, team_colour session_key, driver_number, meeting_key, broadcast_name, first_name,
) VALUES (?, ?, ?, ?, ?) full_name, last_name, name_acronym, headshot_url, team_name, team_colour
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key, meeting_key = excluded.meeting_key,
broadcast_name = excluded.broadcast_name,
first_name = excluded.first_name,
full_name = excluded.full_name,
last_name = excluded.last_name,
name_acronym = excluded.name_acronym,
headshot_url = excluded.headshot_url,
team_name = excluded.team_name, team_name = excluded.team_name,
team_colour = excluded.team_colour team_colour = excluded.team_colour
`, `,
sd.SessionKey, sd.SessionKey,
sd.DriverNumber, sd.DriverNumber,
sd.MeetingKey, sd.MeetingKey,
nullString(sd.BroadcastName),
nullString(sd.FirstName),
nullString(sd.FullName),
nullString(sd.LastName),
nullString(sd.NameAcronym),
nullString(sd.HeadshotURL),
nullString(sd.TeamName), nullString(sd.TeamName),
nullString(sd.TeamColour), nullString(sd.TeamColour),
) )
@@ -110,7 +123,8 @@ func (s *Store) UpsertSessionDriver(sd SessionDriver) error {
// ListSessionDrivers returns drivers entered for a session ordered by number. // ListSessionDrivers returns drivers entered for a session ordered by number.
func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) { func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, team_name, team_colour SELECT session_key, driver_number, meeting_key, broadcast_name, first_name,
full_name, last_name, name_acronym, headshot_url, team_name, team_colour
FROM session_drivers FROM session_drivers
WHERE session_key = ? WHERE session_key = ?
ORDER BY driver_number ASC ORDER BY driver_number ASC
@@ -123,16 +137,29 @@ func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) {
var out []SessionDriver var out []SessionDriver
for rows.Next() { for rows.Next() {
var sd SessionDriver var sd SessionDriver
var broadcastName, firstName, fullName, lastName, nameAcronym, headshotURL sql.NullString
var teamName, teamColour sql.NullString var teamName, teamColour sql.NullString
if err := rows.Scan( if err := rows.Scan(
&sd.SessionKey, &sd.SessionKey,
&sd.DriverNumber, &sd.DriverNumber,
&sd.MeetingKey, &sd.MeetingKey,
&broadcastName,
&firstName,
&fullName,
&lastName,
&nameAcronym,
&headshotURL,
&teamName, &teamName,
&teamColour, &teamColour,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
sd.BroadcastName = broadcastName.String
sd.FirstName = firstName.String
sd.FullName = fullName.String
sd.LastName = lastName.String
sd.NameAcronym = nameAcronym.String
sd.HeadshotURL = headshotURL.String
sd.TeamName = teamName.String sd.TeamName = teamName.String
sd.TeamColour = teamColour.String sd.TeamColour = teamColour.String
out = append(out, sd) out = append(out, sd)

View File

@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SchemaVersion() error = %v", err) t.Fatalf("SchemaVersion() error = %v", err)
} }
if version != 5 { if version != 6 {
t.Fatalf("SchemaVersion() = %d, want 5", version) t.Fatalf("SchemaVersion() = %d, want 6", version)
} }
tables := []string{ tables := []string{

View File

@@ -272,6 +272,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.calendar.loading = true m.calendar.loading = true
m.standings.year = m.year m.standings.year = m.year
m.standings.loading = true m.standings.loading = true
m.standings.err = nil
m.standings.stale = false
m.standings.driverStandings = nil
m.standings.teamStandings = nil
m.standings.drivers = make(map[int]models.Driver)
m.standings.cursor = 0
m.standings.scroll = 0
return m, tea.Batch( return m, tea.Batch(
m.calendar.Init(), m.calendar.Init(),

View File

@@ -5,19 +5,23 @@ import "github.com/AmanTahiliani/box-box/internal/models"
// driverChampionshipLoadedMsg carries the loaded driver championship data. // driverChampionshipLoadedMsg carries the loaded driver championship data.
type driverChampionshipLoadedMsg struct { type driverChampionshipLoadedMsg struct {
standings []models.ChampionshipDriver standings []models.ChampionshipDriver
year int
err error err error
} }
// teamChampionshipLoadedMsg carries the loaded team championship data. // teamChampionshipLoadedMsg carries the loaded team championship data.
type teamChampionshipLoadedMsg struct { type teamChampionshipLoadedMsg struct {
standings []models.ChampionshipTeam standings []models.ChampionshipTeam
year int
err error err error
} }
// standingsDriversLoadedMsg carries drivers for the standings join. // standingsDriversLoadedMsg carries drivers for the standings join.
type standingsDriversLoadedMsg struct { type standingsDriversLoadedMsg struct {
drivers []models.Driver drivers []models.Driver
err error year int
sessionKey int
err error
} }
// meetingsLoadedMsg carries the full meeting list for the calendar. // meetingsLoadedMsg carries the full meeting list for the calendar.

View File

@@ -499,7 +499,13 @@ func (m ReplayModel) renderReplay() string {
name := fmt.Sprintf("#%d", dp.driverNum) name := fmt.Sprintf("#%d", dp.driverNum)
teamColor := colorMuted teamColor := colorMuted
if ok { if ok {
name = d.NameAcronym name = d.FullName
if name == "" {
name = d.BroadcastName
}
if name == "" {
name = d.NameAcronym
}
if d.TeamColour != "" { if d.TeamColour != "" {
teamColor = "#" + d.TeamColour teamColor = "#" + d.TeamColour
} else { } else {
@@ -508,7 +514,10 @@ func (m ReplayModel) renderReplay() string {
} }
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
nameStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).Render(padRight(name, 4)) numberStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).
Render(padRight(fmt.Sprintf("%d", dp.driverNum), 4))
nameStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).
Render(padRight(truncate(name, 18), 18))
posStyled := renderPosition(dp.pos) posStyled := renderPosition(dp.pos)
// Lap time // Lap time
@@ -524,9 +533,10 @@ func (m ReplayModel) renderReplay() string {
Render(fmt.Sprintf("PIT %.1fs", dur)) Render(fmt.Sprintf("PIT %.1fs", dur))
} }
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s\n", sb.WriteString(fmt.Sprintf(" %s %s%s %s %s %s\n",
padRightVisible(posStyled, 4), padRightVisible(posStyled, 4),
colorBar, colorBar,
numberStyled,
nameStyled, nameStyled,
ltStr, ltStr,
pitStr, pitStr,

View File

@@ -65,21 +65,21 @@ func (m StandingsModel) Init() tea.Cmd {
func fetchDriverChampionship(client *api.OpenF1Client, year int) tea.Cmd { func fetchDriverChampionship(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
standings, err := client.GetDriverChampionshipForYear(year) standings, err := client.GetDriverChampionshipForYear(year)
return driverChampionshipLoadedMsg{standings: standings, err: err} return driverChampionshipLoadedMsg{standings: standings, year: year, err: err}
} }
} }
func fetchTeamChampionship(client *api.OpenF1Client, year int) tea.Cmd { func fetchTeamChampionship(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
standings, err := client.GetTeamChampionshipForYear(year) standings, err := client.GetTeamChampionshipForYear(year)
return teamChampionshipLoadedMsg{standings: standings, err: err} return teamChampionshipLoadedMsg{standings: standings, year: year, err: err}
} }
} }
func fetchStandingsDrivers(client *api.OpenF1Client, sessionKey int) tea.Cmd { func fetchStandingsDrivers(client *api.OpenF1Client, year, sessionKey int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
drivers, err := client.GetDriversForSession(sessionKey) drivers, err := client.GetDriversForSession(sessionKey)
return standingsDriversLoadedMsg{drivers: drivers, err: err} return standingsDriversLoadedMsg{drivers: drivers, year: year, sessionKey: sessionKey, err: err}
} }
} }
@@ -99,21 +99,28 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
} }
case driverChampionshipLoadedMsg: case driverChampionshipLoadedMsg:
if msg.year != m.year {
return m, nil
}
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
m.loading = false m.loading = false
return m, nil return m, nil
} }
m.driverStandings = msg.standings m.driverStandings = msg.standings
m.drivers = make(map[int]models.Driver)
if m.client.LastResponseWasStale() { if m.client.LastResponseWasStale() {
m.stale = true m.stale = true
} }
if len(msg.standings) > 0 { if len(msg.standings) > 0 {
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey) return m, fetchStandingsDrivers(m.client, msg.year, msg.standings[0].SessionKey)
} }
m.loading = false m.loading = false
case teamChampionshipLoadedMsg: case teamChampionshipLoadedMsg:
if msg.year != m.year {
return m, nil
}
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
@@ -124,6 +131,12 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
} }
case standingsDriversLoadedMsg: case standingsDriversLoadedMsg:
if msg.year != m.year {
return m, nil
}
if len(m.driverStandings) > 0 && msg.sessionKey != m.driverStandings[0].SessionKey {
return m, nil
}
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
m.loading = false m.loading = false

View File

@@ -557,12 +557,13 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
} }
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey) drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
driverMap := buildDriverMap(drivers) driverMap := buildDriverMapFirst(drivers)
enriched := make([]champDriverWithInfo, 0, len(champ)) enriched := make([]champDriverWithInfo, 0, len(champ))
for _, c := range champ { for _, c := range champ {
e := champDriverWithInfo{ChampionshipDriver: c} e := champDriverWithInfo{ChampionshipDriver: c}
if d, ok := driverMap[c.DriverNumber]; ok { d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap)
if ok {
e.NameAcronym = d.NameAcronym e.NameAcronym = d.NameAcronym
e.FullName = d.FullName e.FullName = d.FullName
e.TeamName = d.TeamName e.TeamName = d.TeamName
@@ -573,6 +574,14 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
writeJSON(w, enriched) writeJSON(w, enriched)
} }
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
return *d, true
}
d, ok := fallback[driverNumber]
return d, ok
}
// --- /api/v1/championship/teams --- // --- /api/v1/championship/teams ---
func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request) {
@@ -1026,6 +1035,16 @@ func buildDriverMap(drivers []models.Driver) map[int]models.Driver {
return m return m
} }
func buildDriverMapFirst(drivers []models.Driver) map[int]models.Driver {
m := make(map[int]models.Driver, len(drivers))
for _, d := range drivers {
if _, exists := m[d.DriverNumber]; !exists {
m[d.DriverNumber] = d
}
}
return m
}
func enrichedResultsToAPI(results []query.EnrichedResult) []resultWithDriver { func enrichedResultsToAPI(results []query.EnrichedResult) []resultWithDriver {
out := make([]resultWithDriver, 0, len(results)) out := make([]resultWithDriver, 0, len(results))
for _, res := range results { for _, res := range results {

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query" "github.com/AmanTahiliani/box-box/internal/query"
"github.com/AmanTahiliani/box-box/internal/store" "github.com/AmanTahiliani/box-box/internal/store"
) )
@@ -151,6 +152,19 @@ func TestHandleRaceHubRequiresSessionKey(t *testing.T) {
} }
} }
func TestBuildDriverMapFirstKeepsFirstDuplicateDriverNumber(t *testing.T) {
drivers := []models.Driver{
{DriverNumber: 4, NameAcronym: "NOR", TeamName: "McLaren"},
{DriverNumber: 4, NameAcronym: "NOR", TeamName: "Red Bull Racing"},
}
driverMap := buildDriverMapFirst(drivers)
driver := driverMap[4]
if driver.TeamName != "McLaren" {
t.Fatalf("team = %q, want McLaren", driver.TeamName)
}
}
func TestHandleSeasonsEmpty(t *testing.T) { func TestHandleSeasonsEmpty(t *testing.T) {
srv := testServer(t, nil) srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil)