Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/app/api/drivers/route.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 44 additions & 0 deletions src/app/api/drivers/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
23 changes: 0 additions & 23 deletions src/app/championships/[year]/page.tsx

This file was deleted.

15 changes: 5 additions & 10 deletions src/app/drivers/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import DriversExplorer from '@/components/drivers/DriversExplorer';

export default function DriversPage() {
return (
<div className="mx-auto w-full max-w-7xl px-6 py-12">
<div className="border-border mb-8 border-b pb-6">
<h1 className="text-foreground text-3xl font-bold">Driver Stats</h1>
<p className="text-muted mt-2">
Lap times, race results, and career statistics for every driver on the
grid.
</p>
</div>
<div className="border-border bg-surface text-muted flex items-center justify-center rounded-xl border py-32">
Driver data coming soon
<div className="h-full min-h-0 w-full overflow-x-hidden overflow-y-auto">
<div className="mx-auto w-full max-w-7xl px-6">
<DriversExplorer />
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function RootLayout({
<body className="bg-background text-foreground flex h-full flex-col overflow-hidden">
<ThemeProvider>
<NavBar />
<main className="flex flex-1 flex-col overflow-hidden">
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
{children}
</main>
</ThemeProvider>
Expand Down
28 changes: 28 additions & 0 deletions src/components/drivers/DriverCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
href={`/drivers/${driver.id}`}
className="group border-border bg-surface hover:bg-surface-hover flex h-full flex-col gap-4 rounded-xl border p-5 transition-colors hover:border-[var(--f1-red)]"
>
<div className="border-border bg-background flex aspect-square items-center justify-center rounded-lg border">
<UserRound
aria-hidden="true"
className="text-muted h-16 w-16 transition-colors group-hover:text-[var(--f1-red)]"
/>
</div>
<h2 className="text-foreground text-base leading-snug font-semibold transition-colors group-hover:text-[var(--f1-red)]">
{driver.name}
</h2>
</Link>
);
}
131 changes: 131 additions & 0 deletions src/components/drivers/DriversExplorer.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<DriversExplorer />);

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(<DriversExplorer />);

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(<DriversExplorer />);

expect(await screen.findByRole('alert')).toHaveTextContent(
/unsupported driver season/i,
);
});
});
Loading
Loading