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