diff --git a/src/app/api/drivers/route.spec.ts b/src/app/api/drivers/route.spec.ts new file mode 100644 index 0000000..ba033a8 --- /dev/null +++ b/src/app/api/drivers/route.spec.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server'; +import { describe, expect, it } from 'vitest'; + +import { GET } from './route'; + +describe('GET /api/drivers', () => { + it('returns driver season data for a valid year query', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/drivers?year=2024', + ); + + const response = await GET(request); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.data.year).toBe(2024); + expect(Array.isArray(payload.data.drivers)).toBe(true); + expect(payload.availableYears[0]).toBe(2024); + }); + + it('returns 400 for an invalid year value', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/drivers?year=twenty-twenty-four', + ); + + const response = await GET(request); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.message).toMatch(/invalid year/i); + }); + + it('uses 2024 as the default year when year is omitted', async () => { + const request = new NextRequest('http://localhost:3000/api/drivers'); + + const response = await GET(request); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.data.year).toBe(2024); + }); +}); \ No newline at end of file diff --git a/src/app/api/drivers/route.ts b/src/app/api/drivers/route.ts new file mode 100644 index 0000000..82f5ef1 --- /dev/null +++ b/src/app/api/drivers/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + getDriversByYear, + isSupportedDriverYear, + toDriverServiceError, +} from '@/lib/server/drivers'; + +export const dynamic = 'force-dynamic'; + +const defaultYear = 2024; + +export async function GET(request: NextRequest) { + const yearParam = request.nextUrl.searchParams.get('year'); + + const year = yearParam ? Number(yearParam) : defaultYear; + + if (Number.isNaN(year) || !Number.isInteger(year)) { + return NextResponse.json( + { message: 'Invalid year query parameter.' }, + { status: 400 }, + ); + } + + if (!isSupportedDriverYear(year)) { + return NextResponse.json( + { message: 'Unsupported driver season.' }, + { status: 400 }, + ); + } + + try { + const response = await getDriversByYear({ year }); + return NextResponse.json(response, { status: 200 }); + } catch (error) { + const serviceError = toDriverServiceError(error); + return NextResponse.json( + { + message: serviceError.message, + }, + { status: serviceError.status }, + ); + } +} \ No newline at end of file diff --git a/src/app/championships/[year]/page.tsx b/src/app/championships/[year]/page.tsx deleted file mode 100644 index 49b6bec..0000000 --- a/src/app/championships/[year]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -interface ChampionshipYearPageProps { - params: Promise<{ year: string }>; -} - -export default async function ChampionshipYearPage({ - params, -}: ChampionshipYearPageProps) { - const { year } = await params; - - return ( -
-
-

Season

-

- {year} Championship -

-
-
- Season detail coming soon -
-
- ); -} diff --git a/src/app/drivers/page.tsx b/src/app/drivers/page.tsx index e2ff212..fa3ec9d 100644 --- a/src/app/drivers/page.tsx +++ b/src/app/drivers/page.tsx @@ -1,15 +1,10 @@ +import DriversExplorer from '@/components/drivers/DriversExplorer'; + export default function DriversPage() { return ( -
-
-

Driver Stats

-

- Lap times, race results, and career statistics for every driver on the - grid. -

-
-
- Driver data coming soon +
+
+
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0c325df..7e88ca5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,7 +33,7 @@ export default function RootLayout({ -
+
{children}
diff --git a/src/components/drivers/DriverCard.tsx b/src/components/drivers/DriverCard.tsx new file mode 100644 index 0000000..77f213e --- /dev/null +++ b/src/components/drivers/DriverCard.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; + +import { UserRound } from 'lucide-react'; + +import type { DriverSeasonCard as DriverSeasonCardType } from '@/types/championship'; + +interface DriverCardProps { + driver: DriverSeasonCardType; +} + +export default function DriverCard({ driver }: DriverCardProps) { + return ( + +
+
+

+ {driver.name} +

+ + ); +} diff --git a/src/components/drivers/DriversExplorer.spec.tsx b/src/components/drivers/DriversExplorer.spec.tsx new file mode 100644 index 0000000..93279ed --- /dev/null +++ b/src/components/drivers/DriversExplorer.spec.tsx @@ -0,0 +1,131 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import DriversExplorer from './DriversExplorer'; + +const seasonPayload = { + availableYears: [2024, 2023, 2022], + data: { + year: 2024, + drivers: [ + { + id: '1', + name: 'Max Verstappen', + latestPoints: 437, + }, + { + id: '16', + name: 'Charles Leclerc', + latestPoints: 356, + }, + { + id: '63', + name: 'George Russell', + latestPoints: 245, + }, + { + id: '99', + name: 'Zero Point Driver', + latestPoints: 0, + }, + ], + }, +}; + +describe('DriversExplorer', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('loads the selected season and renders drivers in points order', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => seasonPayload, + }); + + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/drivers?year=2024', + expect.objectContaining({ cache: 'no-store', method: 'GET' }), + ); + }); + + const list = await screen.findByRole('list', { + name: /drivers for 2024/i, + }); + const names = within(list) + .getAllByRole('heading', { level: 2 }) + .map((heading) => heading.textContent); + + expect(names).toEqual([ + 'Max Verstappen', + 'Charles Leclerc', + 'George Russell', + 'Zero Point Driver', + ]); + }); + + it('refetches when the season changes', async () => { + const user = userEvent.setup(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => seasonPayload, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ...seasonPayload, + data: { + year: 2023, + drivers: [ + { + id: '14', + name: 'Fernando Alonso', + latestPoints: 206, + }, + ], + }, + }), + }); + + vi.stubGlobal('fetch', fetchMock); + + render(); + + expect( + await screen.findByRole('heading', { name: /max verstappen/i }), + ).toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText(/season/i), '2023'); + + expect( + await screen.findByRole('heading', { name: /fernando alonso/i }), + ).toBeInTheDocument(); + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/drivers?year=2023', + expect.objectContaining({ cache: 'no-store', method: 'GET' }), + ); + }); + + it('shows an error state when the request fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ message: 'Unsupported driver season.' }), + }); + + vi.stubGlobal('fetch', fetchMock); + + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /unsupported driver season/i, + ); + }); +}); diff --git a/src/components/drivers/DriversExplorer.tsx b/src/components/drivers/DriversExplorer.tsx new file mode 100644 index 0000000..56572a3 --- /dev/null +++ b/src/components/drivers/DriversExplorer.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import YearSelect from '@/components/championships/YearSelect'; +import type { DriversSeasonResponse } from '@/types/championship'; + +import DriverCard from './DriverCard'; + +const defaultYears = [2024]; + +function buildEndpoint(year: number) { + const query = new URLSearchParams({ + year: String(year), + }); + + return `/api/drivers?${query.toString()}`; +} + +export default function DriversExplorer() { + const [selectedYear, setSelectedYear] = useState(2024); + const [availableYears, setAvailableYears] = useState(defaultYears); + const [payload, setPayload] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + const abortController = new AbortController(); + + async function loadDrivers() { + setIsLoading(true); + setErrorMessage(null); + + try { + const response = await fetch(buildEndpoint(selectedYear), { + method: 'GET', + signal: abortController.signal, + cache: 'no-store', + }); + + if (!response.ok) { + const payload = (await response.json()) as { message?: string }; + throw new Error(payload.message ?? 'Failed to load drivers.'); + } + + const nextPayload = (await response.json()) as DriversSeasonResponse; + + setPayload(nextPayload); + setAvailableYears(nextPayload.availableYears); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + + const fallbackMessage = 'Failed to load drivers.'; + setErrorMessage( + error instanceof Error ? error.message : fallbackMessage, + ); + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + } + + void loadDrivers(); + + return () => { + abortController.abort(); + }; + }, [selectedYear]); + + return ( +
+
+ + {payload ? ( +

+ {payload.data.drivers.length} drivers in {payload.data.year} +

+ ) : null} +
+ +
+ {isLoading ? ( +
+ Loading drivers... +
+ ) : null} + + {!isLoading && errorMessage ? ( +
+

+ Unable to load drivers +

+

{errorMessage}

+
+ ) : null} + + {!isLoading && !errorMessage && payload ? ( +
+ {payload.data.drivers.map((driver) => ( + + ))} +
+ ) : null} +
+
+ ); +} diff --git a/src/lib/mocks/championships.ts b/src/lib/mocks/championships.ts index f08169e..4768ef6 100644 --- a/src/lib/mocks/championships.ts +++ b/src/lib/mocks/championships.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; -import type { ChampionshipYearData } from '@/types/championship'; +import type { ChampionshipYearData, DriversSeasonData } from '@/types/championship'; type RaceInfo = { raceId: number; @@ -231,31 +231,53 @@ function getDataset() { return cachedDataset; } -export const championshipYears = Array.from(getDataset().racesByYear.keys()).sort( - (a, b) => b - a, -); - -export function getChampionshipYearFromMocks(year: number): ChampionshipYearData | null { - const dataset = getDataset(); - const races = dataset.racesByYear.get(year); - - if (!races || races.length === 0) { - return null; - } - +function getSeasonDriverIds(dataset: CsvDataset, races: RaceInfo[]) { const driverIds = new Set(); for (const race of races) { const standings = dataset.cumulativePointsByRaceDriver.get(race.raceId); + const results = dataset.raceResultByRaceDriver.get(race.raceId); if (standings) { for (const driverId of standings.keys()) { driverIds.add(driverId); } } + + if (results) { + for (const driverId of results.keys()) { + driverIds.add(driverId); + } + } } - const drivers = Array.from(driverIds) + return Array.from(driverIds); +} + +function compareDriversByPointsAndName( + left: { name: string; latestPoints: number }, + right: { name: string; latestPoints: number }, +) { + if (right.latestPoints !== left.latestPoints) { + return right.latestPoints - left.latestPoints; + } + + return left.name.localeCompare(right.name); +} + +export const championshipYears = Array.from(getDataset().racesByYear.keys()).sort( + (a, b) => b - a, +); + +export function getChampionshipYearFromMocks(year: number): ChampionshipYearData | null { + const dataset = getDataset(); + const races = dataset.racesByYear.get(year); + + if (!races || races.length === 0) { + return null; + } + + const drivers = getSeasonDriverIds(dataset, races) .map((driverId) => { let latestPoints = 0; @@ -295,13 +317,12 @@ export function getChampionshipYearFromMocks(year: number): ChampionshipYearData color: preferredColor, cumulativePoints, raceResults, + latestPoints, }; }) .filter((driver) => (driver.cumulativePoints.at(-1) ?? 0) > 0) - .sort( - (a, b) => - (b.cumulativePoints.at(-1) ?? 0) - (a.cumulativePoints.at(-1) ?? 0), - ); + .sort(compareDriversByPointsAndName) + .map(({ latestPoints: _latestPoints, ...driver }) => driver); return { year, @@ -309,3 +330,39 @@ export function getChampionshipYearFromMocks(year: number): ChampionshipYearData drivers, }; } + +export function getDriverSeasonFromMocks(year: number): DriversSeasonData | null { + const dataset = getDataset(); + const races = dataset.racesByYear.get(year); + + if (!races || races.length === 0) { + return null; + } + + const drivers = getSeasonDriverIds(dataset, races) + .map((driverId) => { + let latestPoints = 0; + + for (const race of races) { + const racePoints = dataset.cumulativePointsByRaceDriver + .get(race.raceId) + ?.get(driverId); + + if (racePoints !== undefined) { + latestPoints = racePoints; + } + } + + return { + id: String(driverId), + name: dataset.driverNamesById.get(driverId) ?? `Driver ${driverId}`, + latestPoints, + }; + }) + .sort(compareDriversByPointsAndName); + + return { + year, + drivers, + }; +} diff --git a/src/lib/server/drivers.ts b/src/lib/server/drivers.ts new file mode 100644 index 0000000..3211995 --- /dev/null +++ b/src/lib/server/drivers.ts @@ -0,0 +1,54 @@ +import { + championshipYears, + getDriverSeasonFromMocks, +} from '@/lib/mocks/championships'; +import type { DriversSeasonResponse } from '@/types/championship'; + +class DriverServiceError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'DriverServiceError'; + this.status = status; + } +} + +function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function isSupportedDriverYear(year: number) { + return championshipYears.includes(year); +} + +export async function getDriversByYear(params: { + year: number; +}): Promise { + await wait(120); + + if (!isSupportedDriverYear(params.year)) { + throw new DriverServiceError('Requested driver season is not available.', 404); + } + + const yearData = getDriverSeasonFromMocks(params.year); + + if (!yearData) { + throw new DriverServiceError('Requested driver season is not available.', 404); + } + + return { + availableYears: championshipYears, + data: yearData, + }; +} + +export function toDriverServiceError(error: unknown) { + if (error instanceof DriverServiceError) { + return error; + } + + return new DriverServiceError('Unable to load driver data.', 500); +} \ No newline at end of file diff --git a/src/types/championship.ts b/src/types/championship.ts index 2305833..f44825d 100644 --- a/src/types/championship.ts +++ b/src/types/championship.ts @@ -44,3 +44,19 @@ export interface ChampionshipYearResponse { availableYears: number[]; data: ChampionshipYearData; } + +export interface DriverSeasonCard { + id: string; + name: string; + latestPoints: number; +} + +export interface DriversSeasonData { + year: number; + drivers: DriverSeasonCard[]; +} + +export interface DriversSeasonResponse { + availableYears: number[]; + data: DriversSeasonData; +}