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 { 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 (
<div className="missing-notice">
@@ -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 (
<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>
<tr>
<th>Driver</th>
<th className="c">Best Lap</th>
<th className="r">Best Time</th>
<th className="r">Gap</th>
<th className="r hide-mobile">Laps</th>
<th className="r hide-mobile">Pit Outs</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={row.driver_number}>
<td className="mono" style={{ fontWeight: 700 }}>
#{row.driver_number}
<tr key={row.driver_number} className={isFastest ? 'lap-fastest-row' : undefined}>
<td style={{ fontWeight: 700 }}>
<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 className="c mono">
{row.best ? (
<>
{row.best.lap_number}
{isFastest && (
<span style={{ color: 'var(--red)', marginLeft: 6 }}>FASTEST</span>
)}
</>
) : (
'—'
)}
{row.best ? row.best.lap_number : '—'}
</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" style={{ color: 'var(--text-3)' }}>
{row.pitOuts || '—'}

View File

@@ -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) {
</tr>
</thead>
<tbody>
{rows.map((message, index) => (
<tr key={`${message.date}-${index}`}>
<td className="mono" style={{ color: 'var(--text-3)' }}>
{formatEventTime(message.date)}
</td>
<td className="c mono">{message.lap_number ?? '—'}</td>
<td>
<span style={{ fontWeight: 700 }}>{eventLabel(message)}</span>
{message.scope && (
<span style={{ color: 'var(--text-3)', marginLeft: 6 }}>
{message.scope.toLowerCase()}
</span>
)}
</td>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
<td style={{ whiteSpace: 'normal' }}>{message.message || '—'}</td>
</tr>
))}
{rows.map((message, index) => {
const visualClass = eventClass(message)
return (
<tr className={`race-control-row ${visualClass}`} key={`${message.date}-${index}`}>
<td className="mono rc-time-cell">
{formatEventTime(message.date)}
</td>
<td className="c mono">{message.lap_number ?? '—'}</td>
<td>
<span className={`rc-event-pill rc-flag ${visualClass}`}>{eventLabel(message)}</span>
{message.scope && (
<span className="rc-scope">
{message.scope.toLowerCase()}
</span>
)}
</td>
<td className="c mono hide-mobile">{message.driver_number ?? '—'}</td>
<td className="rc-message-cell">{message.message || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>

View File

@@ -109,6 +109,10 @@ const RC_FLAG_CSS: Record<string, string> = {
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',

View File

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

View File

@@ -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(<LapsView laps={laps} />)
render(<LapsView laps={laps} drivers={drivers} />)
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', () => {

View File

@@ -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

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 {
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,
}
}

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 {
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,

View File

@@ -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

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.
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.

View File

@@ -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)

View File

@@ -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{

View File

@@ -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(),

View File

@@ -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.

View File

@@ -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,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)