diff --git a/frontend/src/components/LapsView.tsx b/frontend/src/components/LapsView.tsx index d8c9d60..aef3297 100644 --- a/frontend/src/components/LapsView.tsx +++ b/frontend/src/components/LapsView.tsx @@ -1,8 +1,9 @@ -import type { Lap } from '../types' -import { formatLapTime } from '../utils' +import type { Driver, Lap } from '../types' +import { formatGap, formatLapTime, teamColor } from '../utils' interface Props { laps: Lap[] + drivers?: Driver[] } interface DriverLapSummary { @@ -13,7 +14,7 @@ interface DriverLapSummary { pitOuts: number } -export function LapsView({ laps }: Props) { +export function LapsView({ laps, drivers = [] }: Props) { if (laps.length === 0) { return (
@@ -58,45 +59,52 @@ export function LapsView({ laps }: Props) { 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 fastestTime = fastest?.lap_duration ?? null return (
- +
+ {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 = fastest && row.best?.driver_number === fastest.driver_number && row.best?.lap_number === fastest.lap_number + const gap = + row.best?.lap_duration != null && fastestTime != null + ? row.best.lap_duration - fastestTime + : null return ( - - + + - {rows.map((message, index) => ( - - - - - - - - ))} + {rows.map((message, index) => { + const visualClass = eventClass(message) + return ( + + + + + + + + ) + })}
Driver Best Lap Best TimeGap Laps Pit Outs
- #{row.driver_number} +
+ + + {driverName} + {row.driver_number} + - {row.best ? ( - <> - {row.best.lap_number} - {isFastest && ( - FASTEST - )} - - ) : ( - '—' - )} + {row.best ? row.best.lap_number : '—'} {formatLapTime(row.best?.lap_duration)}{isFastest ? '—' : formatGap(gap)} {row.lastLap || row.total} {row.pitOuts || '—'} diff --git a/frontend/src/components/RaceControlView.tsx b/frontend/src/components/RaceControlView.tsx index c894bec..bd2b1a1 100644 --- a/frontend/src/components/RaceControlView.tsx +++ b/frontend/src/components/RaceControlView.tsx @@ -1,4 +1,5 @@ import type { RaceControlMessage } from '../types' +import { rcFlagClass } from '../lib/live' interface Props { messages: RaceControlMessage[] @@ -19,6 +20,24 @@ function eventLabel(message: RaceControlMessage): string { 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) { if (messages.length === 0) { return ( @@ -44,24 +63,27 @@ export function RaceControlView({ messages }: Props) {
- {formatEventTime(message.date)} - {message.lap_number ?? '—'} - {eventLabel(message)} - {message.scope && ( - - {message.scope.toLowerCase()} - - )} - {message.driver_number ?? '—'}{message.message || '—'}
+ {formatEventTime(message.date)} + {message.lap_number ?? '—'} + {eventLabel(message)} + {message.scope && ( + + {message.scope.toLowerCase()} + + )} + {message.driver_number ?? '—'}{message.message || '—'}
diff --git a/frontend/src/lib/live.ts b/frontend/src/lib/live.ts index 42bb193..a7f38ea 100644 --- a/frontend/src/lib/live.ts +++ b/frontend/src/lib/live.ts @@ -109,6 +109,10 @@ const RC_FLAG_CSS: Record = { YELLOW: 'rc-flag-yellow', 'DOUBLE YELLOW': 'rc-flag-yellow', 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', 'SAFETY CAR': 'rc-flag-sc', VSC: 'rc-flag-vsc', diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index 5b37310..5c98f81 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -315,7 +315,7 @@ export function RaceHubPage({ sessionKey }: Props) { {data.laps.length} samples )}
- + )} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 1ee0aa3..ae96c50 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -421,6 +421,10 @@ a { color: inherit; text-decoration: none; } } .data-table tbody tr:last-child td { border-bottom: none; } .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.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-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-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 { flex-shrink: 0; diff --git a/frontend/src/test/LapsView.test.tsx b/frontend/src/test/LapsView.test.tsx index ef4586e..4da3403 100644 --- a/frontend/src/test/LapsView.test.tsx +++ b/frontend/src/test/LapsView.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { render, screen } from '@testing-library/react' import { LapsView } from '../components/LapsView' -import type { Lap } from '../types' +import type { Driver, Lap } from '../types' 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', () => { it('renders compact best-lap rows by driver', () => { - render() + render() expect(screen.getByTestId('laps-view')).toBeInTheDocument() - expect(screen.getByText('#1')).toBeInTheDocument() - expect(screen.getByText('#44')).toBeInTheDocument() + expect(screen.getByText('Max Verstappen')).toBeInTheDocument() + expect(screen.getByText('Lewis Hamilton')).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', () => { diff --git a/internal/api/openf1.go b/internal/api/openf1.go index 6bd6183..1d36234 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -273,24 +273,12 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) { return 0, errors.New("no Race sessions found") } - // Walk backwards to find the most recent completed race. now := time.Now() - for i := len(sessions) - 1; i >= 0; i-- { - s := sessions[i] - if s.DateEnd != "" { - 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 - } - } + latestKey, ok := latestCompletedRaceSessionKey(sessions, now) + if !ok { + return 0, errors.New("no completed Race sessions found") } - - return 0, errors.New("no completed Race sessions found") + return latestKey, nil } // 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) } - // Walk backwards to find the most recent completed race. now := time.Now() - for i := len(sessions) - 1; i >= 0; i-- { - s := sessions[i] - if s.DateEnd != "" { - endTime, err := time.Parse(time.RFC3339, s.DateEnd) - if err == nil && endTime.Before(now) { - return s.SessionKey, nil - } - } else if s.DateStart != "" { - // Fallback: if no DateEnd, check DateStart + 3 hours as a rough estimate. - startTime, err := time.Parse(time.RFC3339, s.DateStart) - if err == nil && startTime.Add(3*time.Hour).Before(now) { - return s.SessionKey, nil - } + latestKey, ok := latestCompletedRaceSessionKey(sessions, now) + if !ok { + return 0, fmt.Errorf("no completed Race sessions found for year %d", year) + } + return latestKey, nil +} + +func latestCompletedRaceSessionKey(sessions []models.Session, now time.Time) (int, bool) { + var latestKey int + var latestTime time.Time + + 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 diff --git a/internal/api/session_selection_test.go b/internal/api/session_selection_test.go new file mode 100644 index 0000000..bea66de --- /dev/null +++ b/internal/api/session_selection_test.go @@ -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) + } +} diff --git a/internal/ingest/openf1.go b/internal/ingest/openf1.go index 1103c2d..98362a9 100644 --- a/internal/ingest/openf1.go +++ b/internal/ingest/openf1.go @@ -357,11 +357,17 @@ func driverToStore(d models.Driver) store.Driver { func sessionDriverToStore(d models.Driver) store.SessionDriver { return store.SessionDriver{ - SessionKey: d.SessionKey, - DriverNumber: d.DriverNumber, - MeetingKey: d.MeetingKey, - TeamName: d.TeamName, - TeamColour: d.TeamColour, + SessionKey: d.SessionKey, + DriverNumber: d.DriverNumber, + MeetingKey: d.MeetingKey, + BroadcastName: d.BroadcastName, + FirstName: d.FirstName, + FullName: d.FullName, + LastName: d.LastName, + NameAcronym: d.NameAcronym, + HeadshotURL: d.HeadshotURL, + TeamName: d.TeamName, + TeamColour: d.TeamColour, } } diff --git a/internal/query/convert.go b/internal/query/convert.go index 8d6a971..56ddfa9 100644 --- a/internal/query/convert.go +++ b/internal/query/convert.go @@ -40,6 +40,30 @@ func sessionToModel(s store.Session) models.Session { } 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 if teamName == "" { teamName = d.TeamName @@ -49,14 +73,14 @@ func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.D teamColour = d.TeamColour } return models.Driver{ - BroadcastName: d.BroadcastName, + BroadcastName: broadcastName, DriverNumber: sd.DriverNumber, - FirstName: d.FirstName, - FullName: d.FullName, - HeadshotURL: d.HeadshotURL, - LastName: d.LastName, + FirstName: firstName, + FullName: fullName, + HeadshotURL: headshotURL, + LastName: lastName, MeetingKey: meetingKey, - NameAcronym: d.NameAcronym, + NameAcronym: nameAcronym, SessionKey: sessionKey, TeamColour: teamColour, TeamName: teamName, diff --git a/internal/store/coverage_test.go b/internal/store/coverage_test.go index 4c3aa3b..0d35d74 100644 --- a/internal/store/coverage_test.go +++ b/internal/store/coverage_test.go @@ -7,13 +7,13 @@ import ( func TestCoverageCRUD(t *testing.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() if err != nil { t.Fatalf("SchemaVersion() error = %v", err) } - if version != 5 { - t.Fatalf("SchemaVersion() = %d, want 5", version) + if version != 6 { + t.Fatalf("SchemaVersion() = %d, want 6", version) } // Verify session_coverage table exists diff --git a/internal/store/migrations/006_session_driver_identity.sql b/internal/store/migrations/006_session_driver_identity.sql new file mode 100644 index 0000000..8ad4ec2 --- /dev/null +++ b/internal/store/migrations/006_session_driver_identity.sql @@ -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; diff --git a/internal/store/models.go b/internal/store/models.go index 1e5fc87..9528929 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -42,16 +42,16 @@ type NewsSource struct { // NewsItem stores a normalized feed item deduplicated by URL. type NewsItem struct { - URL string - Source string - Title string - PublishedAt *time.Time - Summary string - Category string - FetchedAt time.Time - OGImageURL string - OGDescription string - ReadAt *time.Time + URL string + Source string + Title string + PublishedAt *time.Time + Summary string + Category string + FetchedAt time.Time + OGImageURL string + OGDescription string + ReadAt *time.Time } // 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. type SessionDriver struct { - SessionKey int - DriverNumber int - MeetingKey int - TeamName string - TeamColour string + SessionKey int + DriverNumber int + MeetingKey int + BroadcastName string + FirstName string + FullName string + LastName string + NameAcronym string + HeadshotURL string + TeamName string + TeamColour string } // SessionResult is a final classification row for a session. diff --git a/internal/store/results.go b/internal/store/results.go index 5f81e95..f1401e8 100644 --- a/internal/store/results.go +++ b/internal/store/results.go @@ -88,16 +88,29 @@ func (s *Store) GetDriver(driverNumber int) (Driver, error) { func (s *Store) UpsertSessionDriver(sd SessionDriver) error { _, err := s.db.Exec(` INSERT INTO session_drivers ( - session_key, driver_number, meeting_key, team_name, team_colour - ) VALUES (?, ?, ?, ?, ?) + session_key, driver_number, meeting_key, broadcast_name, first_name, + full_name, last_name, name_acronym, headshot_url, team_name, team_colour + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_key, driver_number) DO UPDATE SET 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_colour = excluded.team_colour `, sd.SessionKey, sd.DriverNumber, 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.TeamColour), ) @@ -110,7 +123,8 @@ func (s *Store) UpsertSessionDriver(sd SessionDriver) error { // ListSessionDrivers returns drivers entered for a session ordered by number. func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) { 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 WHERE session_key = ? ORDER BY driver_number ASC @@ -123,16 +137,29 @@ func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) { var out []SessionDriver for rows.Next() { var sd SessionDriver + var broadcastName, firstName, fullName, lastName, nameAcronym, headshotURL sql.NullString var teamName, teamColour sql.NullString if err := rows.Scan( &sd.SessionKey, &sd.DriverNumber, &sd.MeetingKey, + &broadcastName, + &firstName, + &fullName, + &lastName, + &nameAcronym, + &headshotURL, &teamName, &teamColour, ); err != nil { 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.TeamColour = teamColour.String out = append(out, sd) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9056c46..7af0906 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) { if err != nil { t.Fatalf("SchemaVersion() error = %v", err) } - if version != 5 { - t.Fatalf("SchemaVersion() = %d, want 5", version) + if version != 6 { + t.Fatalf("SchemaVersion() = %d, want 6", version) } tables := []string{ diff --git a/internal/ui/app.go b/internal/ui/app.go index b5437a2..113822c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -272,6 +272,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.calendar.loading = true m.standings.year = m.year 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( m.calendar.Init(), diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 531350b..091b0c2 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -5,19 +5,23 @@ import "github.com/AmanTahiliani/box-box/internal/models" // driverChampionshipLoadedMsg carries the loaded driver championship data. type driverChampionshipLoadedMsg struct { standings []models.ChampionshipDriver + year int err error } // teamChampionshipLoadedMsg carries the loaded team championship data. type teamChampionshipLoadedMsg struct { standings []models.ChampionshipTeam + year int err error } // standingsDriversLoadedMsg carries drivers for the standings join. type standingsDriversLoadedMsg struct { - drivers []models.Driver - err error + drivers []models.Driver + year int + sessionKey int + err error } // meetingsLoadedMsg carries the full meeting list for the calendar. diff --git a/internal/ui/replay.go b/internal/ui/replay.go index 9a14b6f..802eebd 100644 --- a/internal/ui/replay.go +++ b/internal/ui/replay.go @@ -499,7 +499,13 @@ func (m ReplayModel) renderReplay() string { name := fmt.Sprintf("#%d", dp.driverNum) teamColor := colorMuted if ok { - name = d.NameAcronym + name = d.FullName + if name == "" { + name = d.BroadcastName + } + if name == "" { + name = d.NameAcronym + } if d.TeamColour != "" { teamColor = "#" + d.TeamColour } else { @@ -508,7 +514,10 @@ func (m ReplayModel) renderReplay() string { } 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) // Lap time @@ -524,9 +533,10 @@ func (m ReplayModel) renderReplay() string { 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), colorBar, + numberStyled, nameStyled, ltStr, pitStr, diff --git a/internal/ui/standings.go b/internal/ui/standings.go index 39bd625..d668136 100644 --- a/internal/ui/standings.go +++ b/internal/ui/standings.go @@ -65,21 +65,21 @@ func (m StandingsModel) Init() tea.Cmd { func fetchDriverChampionship(client *api.OpenF1Client, year int) tea.Cmd { return func() tea.Msg { 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 { return func() tea.Msg { 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 { 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: + if msg.year != m.year { + return m, nil + } if msg.err != nil { m.err = msg.err m.loading = false return m, nil } m.driverStandings = msg.standings + m.drivers = make(map[int]models.Driver) if m.client.LastResponseWasStale() { m.stale = true } 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 case teamChampionshipLoadedMsg: + if msg.year != m.year { + return m, nil + } if msg.err != nil { m.err = msg.err return m, nil @@ -124,6 +131,12 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) { } 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 { m.err = msg.err m.loading = false diff --git a/internal/web/api.go b/internal/web/api.go index d3b903d..9c84c01 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -557,12 +557,13 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques } drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey) - driverMap := buildDriverMap(drivers) + driverMap := buildDriverMapFirst(drivers) enriched := make([]champDriverWithInfo, 0, len(champ)) for _, c := range champ { 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.FullName = d.FullName e.TeamName = d.TeamName @@ -573,6 +574,14 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques 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 --- 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 } +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 { out := make([]resultWithDriver, 0, len(results)) for _, res := range results { diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go index f8e74cb..fd95b7f 100644 --- a/internal/web/racehub_test.go +++ b/internal/web/racehub_test.go @@ -9,6 +9,7 @@ import ( "time" "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/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) { srv := testServer(t, nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil)