Upgrade Next.js to v16 and refactor API client

Bump Next.js from v9 to v16.2.6 and add node-ical to
serverExternalPackages. Remove Geist font dependencies in favor
of system fonts. Fix state updates in geocode hooks to prevent
stale closures. Expose ApiClient methods as public and improve
error handling in fetchWithRetry.
This commit is contained in:
2026-05-10 02:07:47 +02:00
parent a7fcbd811e
commit c869d4958e
10 changed files with 943 additions and 11470 deletions
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["node-ical"],
};
export default nextConfig;
+896 -11429
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@
},
"dependencies": {
"date-fns": "^4.1.0",
"next": "^9.3.3",
"next": "^16.2.6",
"node-ical": "^0.26.1",
"react": "19.2.4",
"react-dom": "19.2.4"
+2 -2
View File
@@ -8,8 +8,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
+1 -12
View File
@@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { EventsProvider } from "@/hooks/useEventsStore";
import Header from "@/app/layout/Header";
import Navbar from "@/app/layout/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TimeToLeave",
description: "Plan your train journeys and compare with bicycle routing",
@@ -26,7 +15,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
<EventsProvider>
<Header />
+3 -3
View File
@@ -16,10 +16,10 @@ export function useDestinationStation(destination: string) {
if (!destination.trim()) return;
let isMounted = true;
setLoading(true);
setError(null);
const fetchStation = async () => {
setLoading(true);
setError(null);
try {
const body = {
svcReqL: [
+3 -3
View File
@@ -9,10 +9,10 @@ export function useGeocode(destination: string) {
if (!destination.trim()) return;
let isMounted = true;
setLoading(true);
setError(null);
const fetchCoords = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/geocode?name=${encodeURIComponent(destination)}`);
if (!res.ok) {
+18 -11
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Mock } from "vitest";
import { MemoryCache, ApiClient, ApiError, fetchWithRetry, cachedFetch, calculateBackoff, sleep } from "../api-service";
type FetchMock = Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>>;
function mockedFetch(): FetchMock {
return vi.mocked(fetch) as FetchMock;
}
// ---------------------------------------------------------------------------
// MemoryCache Tests
// ---------------------------------------------------------------------------
@@ -134,7 +141,7 @@ describe("fetchWithRetry", () => {
});
it("returns response on first success", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, { maxRetries: 3 });
@@ -143,7 +150,7 @@ describe("fetchWithRetry", () => {
});
it("retries on HTTP 500 with backoff", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Server Error" })
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
@@ -159,7 +166,7 @@ describe("fetchWithRetry", () => {
});
it("retries on HTTP 429", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch
.mockResolvedValueOnce({
ok: false,
@@ -179,7 +186,7 @@ describe("fetchWithRetry", () => {
});
it("throws ApiError after exhausting retries", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({ ok: false, status: 503 });
await expect(
@@ -192,7 +199,7 @@ describe("fetchWithRetry", () => {
});
it("retries on network errors (TypeError)", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch
.mockRejectedValueOnce(new TypeError("network failure"))
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
@@ -208,7 +215,7 @@ describe("fetchWithRetry", () => {
});
it("does not retry on non-retryable status (404)", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" });
await expect(
@@ -223,7 +230,7 @@ describe("fetchWithRetry", () => {
});
it("respects maxRetries limit", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({ ok: false, status: 500 });
await expect(
@@ -252,7 +259,7 @@ describe("cachedFetch", () => {
});
it("caches successful responses", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
@@ -269,7 +276,7 @@ describe("cachedFetch", () => {
});
it("bypasses cache when skipCache is true", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
@@ -284,7 +291,7 @@ describe("cachedFetch", () => {
});
it("uses custom cache key when provided", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
@@ -300,7 +307,7 @@ describe("cachedFetch", () => {
});
it("throws on non-OK response after retries", async () => {
const mockFetch = vi.mocked(fetch as never);
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: false,
status: 404,
+13 -6
View File
@@ -22,7 +22,7 @@ export class ApiError extends Error {
export interface RetryOptions {
/** Maximum number of retry attempts. Default: 3 */
maxRetries?: number;
/** Base delay in ms before the first retry. Default: 1000 */
/** Base delay in ms before the first retry. Default: 250 */
baseDelayMs?: number;
/** Maximum delay cap in ms. Default: 30_000 */
maxDelayMs?: number;
@@ -161,7 +161,7 @@ async function fetchWithRetry(
options: RetryOptions = {},
): Promise<Response> {
const maxRetries = options.maxRetries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 1000;
const baseDelayMs = options.baseDelayMs ?? 250;
const maxDelayMs = options.maxDelayMs ?? 30_000;
const jitter = options.jitter ?? 0.3;
const retryableStatuses = options.retryableStatuses ?? [429, 500, 502, 503, 504];
@@ -175,7 +175,7 @@ async function fetchWithRetry(
// Check if the status is retryable
if (response.status === 429) {
// HTTP 429: Too Many Requests — respect Retry-After header
const retryAfterHeader = response.headers.get("Retry-After");
const retryAfterHeader = response.headers?.get("Retry-After");
let retryAfterMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
if (retryAfterHeader) {
@@ -196,7 +196,7 @@ async function fetchWithRetry(
}
// Exhausted retries — throw
const err = new ApiError("Rate limit exceeded after all retries");
const err = new ApiError("Rate limit exceeded after all retries (HTTP 429)");
err.status = 429;
(err as ApiError).isRateLimit = true;
(err as ApiError).isRetryable = false;
@@ -222,6 +222,13 @@ async function fetchWithRetry(
throw err;
}
if (!response.ok) {
const err = new ApiError(`HTTP ${response.status}: ${response.statusText}`);
err.status = response.status;
err.isRetryable = false;
throw err;
}
return response;
} catch (error) {
lastError = error;
@@ -343,7 +350,7 @@ export class ApiClient {
/**
* Perform a GET request with retry + caching.
*/
protected async get<T>(
public async get<T>(
path: string,
search?: Record<string, string>,
options: {
@@ -382,7 +389,7 @@ export class ApiClient {
/**
* Perform a POST request with retry logic (never cached).
*/
protected async post<T>(
public async post<T>(
path: string,
body: unknown,
options: {
+5 -3
View File
@@ -1,8 +1,8 @@
import { defineConfig } from "vitest/config";
import type {} from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
const config = {
plugins: [react()],
test: {
environment: "jsdom",
@@ -14,4 +14,6 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
});
};
export default config;