diff --git a/frontend/src/components/TeammateH2H.tsx b/frontend/src/components/TeammateH2H.tsx
new file mode 100644
index 0000000..fd4f77a
--- /dev/null
+++ b/frontend/src/components/TeammateH2H.tsx
@@ -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 (
+
+
+ {teamName}
+ {extraNote ? {extraNote} : null}
+
+
+
{driverATla}
+
+
+
+
+ {winsA}–{winsB}
+
+
+
{driverBTla}
+
+
+ )
+}
diff --git a/frontend/src/lib/h2h.ts b/frontend/src/lib/h2h.ts
new file mode 100644
index 0000000..0b5defc
--- /dev/null
+++ b/frontend/src/lib/h2h.ts
@@ -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): TeammatePair[] {
+ const byTeam = new Map()
+ 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)
+ })
+}
diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx
index b922202..fdfd21b 100644
--- a/frontend/src/pages/ChampionshipPage.tsx
+++ b/frontend/src/pages/ChampionshipPage.tsx
@@ -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 (
@@ -340,6 +347,29 @@ function DriversView({
{titleMath}
+ {h2hPairs.length > 0 && (
+
+
+
Teammate battles
+ race finishes · closest first
+
+
+ {h2hPairs.map((pair) => (
+ 0 ? `+${pair.extraCount}` : undefined}
+ />
+ ))}
+
+
+ )}
+
diff --git a/frontend/src/styles/h2h.css b/frontend/src/styles/h2h.css
new file mode 100644
index 0000000..676c8f0
--- /dev/null
+++ b/frontend/src/styles/h2h.css
@@ -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%;
+ }
+}
diff --git a/frontend/src/test/ChampionshipPage.test.tsx b/frontend/src/test/ChampionshipPage.test.tsx
index 35c5774..0b19d69 100644
--- a/frontend/src/test/ChampionshipPage.test.tsx
+++ b/frontend/src/test/ChampionshipPage.test.tsx
@@ -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 6–5 is closer than Red Bull 9–1 — McLaren row first.
+ expect(rows[0]).toHaveTextContent('McLaren')
+ expect(rows[0]).toHaveTextContent('6–5')
+ expect(rows[1]).toHaveTextContent('Red Bull')
+ expect(rows[1]).toHaveTextContent('9–1')
+ })
})
diff --git a/frontend/src/test/TeammateH2H.test.tsx b/frontend/src/test/TeammateH2H.test.tsx
new file mode 100644
index 0000000..d67fa53
--- /dev/null
+++ b/frontend/src/test/TeammateH2H.test.tsx
@@ -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(
+ ,
+ )
+ expect(screen.getByText('VER')).toBeInTheDocument()
+ expect(screen.getByText('PER')).toBeInTheDocument()
+ expect(screen.getByTestId('h2h-score')).toHaveTextContent('9–1')
+ expect(screen.getByText('Red Bull')).toBeInTheDocument()
+ })
+
+ it('sets proportional bar segment widths from wins', () => {
+ render(
+ ,
+ )
+ 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(
+ ,
+ )
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/h2h.test.ts b/frontend/src/test/h2h.test.ts
new file mode 100644
index 0000000..b4a0d49
--- /dev/null
+++ b/frontend/src/test/h2h.test.ts
@@ -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 {
+ 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)
+ })
+})