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) + }) +})