Merge pull request #65 from AmanTahiliani/feat/issue-23-teammate-h2h-surfacing

Teammate H2H surfacing (#23)
This commit is contained in:
Aman Tahiliani
2026-07-11 18:25:51 -04:00
committed by GitHub
7 changed files with 505 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
import { teamColor } from '../utils'
import '../styles/h2h.css'
export interface TeammateH2HProps {
teamName: string
teamColour: string
driverATla: string
driverBTla: string
winsA: number
winsB: number
/** e.g. "+1" when more than two drivers share the team. */
extraNote?: string
}
export function TeammateH2H({
teamName,
teamColour,
driverATla,
driverBTla,
winsA,
winsB,
extraNote,
}: TeammateH2HProps) {
const total = winsA + winsB || 1
const pctA = (winsA / total) * 100
const color = teamColor(teamColour)
return (
<div className="h2h-row" data-testid="teammate-h2h">
<div className="h2h-team">
<span className="h2h-team-name">{teamName}</span>
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
</div>
<div className="h2h-bar-wrap">
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
<div className="h2h-bar" data-testid="h2h-bar">
<span
className="h2h-bar-seg"
data-testid="h2h-bar-a"
style={{ width: `${pctA}%`, background: color }}
/>
<span
className="h2h-bar-seg h2h-bar-seg--muted"
data-testid="h2h-bar-b"
style={{ width: `${100 - pctA}%`, background: color }}
/>
<span className="h2h-score mono" data-testid="h2h-score">
{winsA}{winsB}
</span>
</div>
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
</div>
</div>
)
}

47
frontend/src/lib/h2h.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { ChampHubDriver } from '../types'
export interface TeammatePair {
teamName: string
teamColour: string
driverA: ChampHubDriver
driverB: ChampHubDriver
/** Drivers on the team beyond the top two (shown as +N). */
extraCount: number
/** Absolute win gap — smaller means a closer battle. */
closeness: number
}
/** Pair teammates per team for H2H display; skip single-driver teams. */
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
const byTeam = new Map<string, ChampHubDriver[]>()
for (const d of drivers) {
const list = byTeam.get(d.team_name) ?? []
list.push(d)
byTeam.set(d.team_name, list)
}
const pairs: TeammatePair[] = []
for (const [teamName, members] of byTeam) {
if (members.length < 2) continue
const sorted = [...members].sort((a, b) => b.points - a.points)
const driverA = sorted[0]
const driverB = sorted[1]
const winsA = driverA.teammate_wins
const winsB = driverB.teammate_wins
pairs.push({
teamName,
teamColour: driverA.team_colour,
driverA,
driverB,
extraCount: members.length - 2,
closeness: Math.abs(winsA - winsB),
})
}
return pairs.sort((a, b) => {
if (a.closeness !== b.closeness) return a.closeness - b.closeness
return a.teamName.localeCompare(b.teamName)
})
}

View File

@@ -6,6 +6,8 @@ import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { Meaning } from '../components/Meaning'
import { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
@@ -243,6 +245,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
drivers={drivers}
season={hub.season}
/>
)}
@@ -273,15 +276,19 @@ function DriversView({
leaderPoints,
titleMath,
roundsLeft,
drivers,
season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
drivers: ChampHubDriver[]
season: number
}) {
const podium = enriched.slice(0, 3)
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
return (
<div data-testid="champ-view-drivers">
<div className="champ-podium">
@@ -340,6 +347,29 @@ function DriversView({
<span className="champ-titlemath-text">{titleMath}</span>
</div>
{h2hPairs.length > 0 && (
<section className="h2h-section" data-testid="champ-teammate-battles">
<div className="h2h-section-head">
<h2 className="h2h-section-title">Teammate battles</h2>
<span className="h2h-section-sub">race finishes · closest first</span>
</div>
<div className="h2h-list">
{h2hPairs.map((pair) => (
<TeammateH2H
key={pair.teamName}
teamName={pair.teamName}
teamColour={pair.teamColour}
driverATla={pair.driverA.name_acronym}
driverBTla={pair.driverB.name_acronym}
winsA={pair.driverA.teammate_wins}
winsB={pair.driverB.teammate_wins}
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
/>
))}
</div>
</section>
)}
<div className="champ-scroll">
<table className="champ-table champ-table-drivers">
<thead>

130
frontend/src/styles/h2h.css Normal file
View File

@@ -0,0 +1,130 @@
/* Teammate head-to-head bars (issue #23). */
.h2h-section {
margin: 20px 0 24px;
padding: 16px 18px;
background: var(--surface-1, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border);
border-radius: 8px;
}
.h2h-section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.h2h-section-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-2);
}
.h2h-section-sub {
font-size: 11px;
font-family: var(--f-mono);
color: var(--text-3);
}
.h2h-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.h2h-row {
display: grid;
grid-template-columns: minmax(100px, 140px) 1fr;
align-items: center;
gap: 12px;
}
.h2h-team {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.h2h-team-name {
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.h2h-extra {
font-size: 10px;
color: var(--text-3);
flex-shrink: 0;
}
.h2h-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.h2h-tla {
font-size: 11px;
font-weight: 700;
color: var(--text);
flex-shrink: 0;
width: 28px;
}
.h2h-tla--left {
text-align: right;
}
.h2h-tla--right {
text-align: left;
}
.h2h-bar {
position: relative;
flex: 1;
display: flex;
height: 22px;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
}
.h2h-bar-seg {
height: 100%;
transition: width 0.2s ease;
}
.h2h-bar-seg--muted {
opacity: 0.35;
}
.h2h-score {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
@media (max-width: 640px) {
.h2h-row {
grid-template-columns: 1fr;
gap: 4px;
}
.h2h-bar-wrap {
width: 100%;
}
}

View File

@@ -150,4 +150,61 @@ describe('ChampionshipPage', () => {
})
expect(screen.getByText('No championship data')).toBeInTheDocument()
})
it('renders teammate battles section ordered by closest split', async () => {
const h2hDrivers: ChampHubDriver[] = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
position: 2,
teammate_wins: 6,
teammate_losses: 5,
cumulative: [18, 36, 54, 80, 120, 160],
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
position: 3,
teammate_wins: 5,
teammate_losses: 6,
cumulative: [12, 28, 45, 70, 110, 140],
}),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 60,
position: 4,
teammate_wins: 1,
teammate_losses: 9,
cumulative: [5, 12, 20, 35, 50, 60],
}),
]
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
})
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
const rows = screen.getAllByTestId('teammate-h2h')
expect(rows).toHaveLength(2)
// McLaren 65 is closer than Red Bull 91 — McLaren row first.
expect(rows[0]).toHaveTextContent('McLaren')
expect(rows[0]).toHaveTextContent('65')
expect(rows[1]).toHaveTextContent('Red Bull')
expect(rows[1]).toHaveTextContent('91')
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { TeammateH2H } from '../components/TeammateH2H'
describe('TeammateH2H', () => {
it('renders score and TLAs', () => {
render(
<TeammateH2H
teamName="Red Bull"
teamColour="3671c6"
driverATla="VER"
driverBTla="PER"
winsA={9}
winsB={1}
/>,
)
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('PER')).toBeInTheDocument()
expect(screen.getByTestId('h2h-score')).toHaveTextContent('91')
expect(screen.getByText('Red Bull')).toBeInTheDocument()
})
it('sets proportional bar segment widths from wins', () => {
render(
<TeammateH2H
teamName="McLaren"
teamColour="ff8000"
driverATla="NOR"
driverBTla="PIA"
winsA={6}
winsB={4}
/>,
)
const barA = screen.getByTestId('h2h-bar-a')
const barB = screen.getByTestId('h2h-bar-b')
expect(barA).toHaveStyle({ width: '60%' })
expect(barB).toHaveStyle({ width: '40%' })
})
it('shows extra driver note when provided', () => {
render(
<TeammateH2H
teamName="Red Bull"
teamColour="3671c6"
driverATla="VER"
driverBTla="PER"
winsA={5}
winsB={3}
extraNote="+1"
/>,
)
expect(screen.getByText('+1')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import { teammatePairs } from '../lib/h2h'
import type { ChampHubDriver } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 200,
position: 1,
wins: 5,
podiums: 8,
poles: 4,
form: [],
cumulative: [],
teammate_wins: 9,
teammate_losses: 1,
...over,
}
}
describe('teammatePairs', () => {
it('returns empty for no drivers', () => {
expect(teammatePairs([])).toEqual([])
})
it('skips teams with a single driver', () => {
const drivers = [
driver({ driver_number: 1, team_name: 'Red Bull' }),
driver({ driver_number: 44, name_acronym: 'HAM', team_name: 'Mercedes', points: 50 }),
]
expect(teammatePairs(drivers)).toHaveLength(0)
})
it('pairs two-driver teams', () => {
const drivers = [
driver({ driver_number: 1, name_acronym: 'VER', points: 200, teammate_wins: 9, teammate_losses: 1 }),
driver({
driver_number: 11,
name_acronym: 'PER',
full_name: 'Sergio Perez',
points: 60,
teammate_wins: 1,
teammate_losses: 9,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(1)
expect(pairs[0].teamName).toBe('Red Bull')
expect(pairs[0].driverA.name_acronym).toBe('VER')
expect(pairs[0].driverB.name_acronym).toBe('PER')
expect(pairs[0].extraCount).toBe(0)
expect(pairs[0].closeness).toBe(8)
})
it('uses top two by points for teams with 3+ drivers', () => {
const drivers = [
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200 }),
driver({
driver_number: 11,
name_acronym: 'PER',
team_name: 'Red Bull',
points: 60,
teammate_wins: 1,
teammate_losses: 9,
}),
driver({
driver_number: 99,
name_acronym: 'LAW',
full_name: 'Liam Lawson',
team_name: 'Red Bull',
points: 10,
teammate_wins: 0,
teammate_losses: 0,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(1)
expect(pairs[0].driverA.name_acronym).toBe('VER')
expect(pairs[0].driverB.name_acronym).toBe('PER')
expect(pairs[0].extraCount).toBe(1)
})
it('sorts by closest battle first', () => {
const drivers = [
driver({
driver_number: 1,
name_acronym: 'VER',
team_name: 'Red Bull',
points: 200,
teammate_wins: 10,
teammate_losses: 0,
}),
driver({
driver_number: 11,
name_acronym: 'PER',
team_name: 'Red Bull',
points: 60,
teammate_wins: 0,
teammate_losses: 10,
}),
driver({
driver_number: 4,
name_acronym: 'NOR',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 160,
teammate_wins: 6,
teammate_losses: 5,
}),
driver({
driver_number: 81,
name_acronym: 'PIA',
full_name: 'Oscar Piastri',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 140,
teammate_wins: 5,
teammate_losses: 6,
}),
]
const pairs = teammatePairs(drivers)
expect(pairs).toHaveLength(2)
expect(pairs[0].teamName).toBe('McLaren')
expect(pairs[0].closeness).toBe(1)
expect(pairs[1].teamName).toBe('Red Bull')
expect(pairs[1].closeness).toBe(10)
})
})