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