Files
time_to_leave/REWRITE_PLAN.md
fegger 77b8c6db98 Rewrite implementation checklist and fix runtime bugs
Update CHECKLIST.md and REWRITE_PLAN.md to reflect the current
post-rewrite status and remaining tasks.

- Add input validation to /api/hafas route to enforce request shape
  and cap results
- Fix SSR crash in useBikeRoute by using relative fetch URLs
- Wire CalendarPanel to fetch calendar events and merge them into the
  global events store
2026-05-10 03:09:38 +02:00

572 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TimeToLeave — Post-Rewrite Fix Plan
> **Status:** In Progress
> **Updated:** After post-rewrite code review
> **Scope:** Fix blocking bugs, deduplicate code, improve performance/UX, restore tests
---
## 1. Current State
| Layer | Technology | Status |
|-------|-----------|--------|
| Framework | Next.js 16 + App Router | ✅ Working |
| Language | TypeScript (strict) | ✅ Compiles clean |
| State | React Context + localStorage | ✅ Working |
| Styling | Tailwind CSS v4 (dark classes present) | ⚠️ No toggle |
| Tests | Vitest + jsdom + RTL | ⚠️ Setup file missing |
| Build | Docker multi-stage standalone | ✅ Working |
| HAFAS Integration | ApiClient + HafasClient + route | ⚠️ Duplicated parsing |
| Geocoding | ApiClient + GeocodingClient | ⚠️ Client unused by route |
| Bike Routing | ApiClient + BikeRoutingClient | ⚠️ Client unused by route |
---
## 2. Findings Summary
### 🔴 Blocking (3 issues)
| # | Issue | File | Impact |
|---|-------|------|--------|
| 1 | SSR crash: `window.location.href` in `useBikeRoute` | `src/hooks/useBikeRoute.ts:22` | Crashes during server-side rendering |
| 2 | Calendar events lost: `useCalendar` never writes to `EventsStore` | `src/hooks/useCalendar.ts`, calendar pages | Imported events disappear on navigation |
| 3 | No input validation on `/api/hafas` | `src/app/api/hafas/route.ts` | Any client can abuse ÖBB API through proxy |
### 🟡 Code Quality (6 issues)
| # | Issue | File | Impact |
|---|-------|------|--------|
| 4 | HAFAS journey parsing duplicated | `hafas-client.ts` + `useJourneys.ts` | Maintenance burden, drift risk |
| 5 | `GeocodingClient` / `BikeRoutingClient` never used by API routes | `api/geocode/route.ts`, `api/bike-route/route.ts` | Dead code, routes miss caching/retry |
| 6 | O(n×m) calendar cell rendering | `CalendarView.tsx` | Slow with many events |
| 7 | No debounce on geocode/station lookups | `useGeocode.ts`, `useDestinationStation.ts` | Fires API call per keystroke |
| 8 | `LiveStatusUtils` is an empty stub | `live-status-utils.ts` | Dead code |
| 9 | Dark mode classes present but no toggle | `layout.tsx`, `Header.tsx` | Users can't switch themes |
### 🟢 Minor (3 issues)
| # | Issue | File | Impact |
|---|-------|------|--------|
| 10 | Bike route API doesn't request `steps=true` | `api/bike-route/route.ts` | Steps always empty |
| 11 | Test setup file missing | `src/test/setup.ts` | Tests may fail |
| 12 | No correlation IDs in API errors | All API routes | Hard to debug production issues |
---
## 3. Implementation Phases
### Phase 1 — Unblock Runtime (~45 min)
Fix bugs that crash the app or lose user data.
---
#### Step 1: Fix SSR Crash in `useBikeRoute` (~10 min)
**File:** `src/hooks/useBikeRoute.ts`
**Problem:** Line 22 calls `new URL("/api/bike-route", window.location.href)` which throws during SSR because `window` is undefined.
**Change:** Replace the `new URL` + `searchParams` pattern with a relative fetch URL:
```ts
// BEFORE:
const url = new URL("/api/bike-route", window.location.href);
url.searchParams.set("fromLat", String(fromLat));
url.searchParams.set("fromLng", String(fromLng));
url.searchParams.set("toLat", String(toLat));
url.searchParams.set("toLng", String(toLng));
const response = await fetch(url.toString());
// AFTER:
const response = await fetch(
`/api/bike-route?fromLat=${fromLat}&fromLng=${fromLng}&toLat=${toLat}&toLng=${toLng}`,
);
```
**Why it works:** Next.js rewrites relative fetch URLs to the correct origin during SSR. In the browser, relative URLs resolve to the current origin. No `window` needed.
---
#### Step 2: Wire Calendar Events into `EventsStore` (~15 min)
**Files to read:** `src/app/calendar/UrlTab.tsx`, `src/app/calendar/FileTab.tsx`
**Problem:** `useCalendar` holds events in local hook state. The global `EventsProvider` is never updated. When the user navigates away from `/calendar`, imported events are gone.
**Approach:** After a successful import in `UrlTab` and `FileTab`, merge the results into the global store.
```tsx
// In UrlTab.tsx and FileTab.tsx, after successful fetch:
const { mergeEvents } = useEventsStore();
// Convert CalendarEvent[] (string eventTime) → Event[] (Date eventTime)
const storeEvents: Event[] = calendarEvents.map((e) => ({
id: e.id,
title: e.title,
destination: e.destination,
eventTime: new Date(e.eventTime),
source: e.source,
}));
mergeEvents(storeEvents);
```
**Key detail:** `CalendarEvent.eventTime` is an ISO string, `Event.eventTime` is a `Date`. The conversion must happen before storing.
---
#### Step 3: Add Input Validation to `/api/hafas` (~20 min)
**File:** `src/app/api/hafas/route.ts`
**Problem:** `request.json()` is forwarded directly to the ÖBB API with no validation. A malicious or buggy client can send arbitrary methods.
**Add before the `fetch(HAFAS_URL)` call:**
```ts
// Validate body shape
if (!body || !Array.isArray(body.svcReqL) || body.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
}
const svcReq = body.svcReqL[0];
const allowedMethods = ["TripSearch", "LocMatch"];
if (!svcReq || typeof svcReq !== "object" || !allowedMethods.includes(svcReq.meth)) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
}
// Optional: limit TripSearch results
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
svcReq.req.numF = 10;
}
```
---
### Phase 2 — Deduplicate Code (~2 hours)
Eliminate duplicated logic so each integration has one source of truth.
---
#### Step 4: Consolidate HAFAS Journey Parsing (~40 min)
**Files:** `src/lib/hafas-client.ts`, `src/hooks/useJourneys.ts`
**Problem:** Both files contain nearly identical logic to parse `HafasJourney[]``Journey[]`. The `hafas-client.ts` `fetchJourneys()` method is never called (the API route is a dumb proxy, and `useJourneys` does its own parsing).
**Option A — Export shared parser (minimal risk, recommended):**
1. Move the `parseHafasJourneys` function from `useJourneys.ts` into `hafas-client.ts` and export it.
2. In `useJourneys.ts`, import and call `parseHafasJourneys` from `@/lib/hafas-client`.
**Option B — Full refactor (more work, cleaner long-term):**
1. Make `api/hafas/route.ts` use `HafasClient` internally instead of proxying raw JSON.
2. Have the route return parsed `Journey[]` or `Station[]` directly.
3. Remove all HAFAS response parsing from hooks — they trust the API route's output shape.
Start with **Option A** to eliminate duplication quickly. Pursue **Option B** later if the HAFAS response shape changes.
---
#### Step 5: Wire API Routes to Use Library Clients (~30 min)
**Files:** `src/app/api/geocode/route.ts`, `src/app/api/bike-route/route.ts`
**Problem:** `GeocodingClient` and `BikeRoutingClient` are fully implemented with retry + caching via `ApiClient`, but the API routes bypass them with raw `fetch()`.
**Changes — `api/geocode/route.ts`:**
```ts
import { GeocodingClient } from "@/lib/geocoding-client";
// Module-level singleton — cache persists across requests
const client = new GeocodingClient();
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const name = searchParams.get("name");
const countrycodes = searchParams.get("countrycodes");
if (!name) {
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
}
const results = await client.geocode(name, countrycodes || undefined);
if (results.length === 0) {
return NextResponse.json({ error: "No results found" }, { status: 404 });
}
return NextResponse.json(results[0]);
}
```
**Changes — `api/bike-route/route.ts`:**
```ts
import { BikeRoutingClient } from "@/lib/bike-routing-client";
const client = new BikeRoutingClient();
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 },
);
}
const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
if (!route) {
return NextResponse.json({ error: "No route found" }, { status: 404 });
}
return NextResponse.json(route);
}
```
**Also add `steps=true` to `BikeRoutingClient.getBikeRoute`** (see Step 11).
---
#### Step 6: Remove Dead Code (~5 min)
**File:** `src/lib/live-status-utils.ts`
Delete the file entirely. `LiveStatusUtils` returns `null` for everything and is never imported.
**Also:** Remove the export from `src/lib/index.ts`:
```ts
// REMOVE this line:
export * from "./live-status-utils";
```
---
#### Step 7: Create Missing Test Setup File (~10 min)
**File:** `src/test/setup.ts`
`vitest.config.ts` references this file but it doesn't exist. Create it:
```ts
import "@testing-library/jest-dom/vitest";
```
This registers the `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toHaveTextContent`, etc.) globally for Vitest.
---
### Phase 3 — Performance & UX (~1.5 hours)
---
#### Step 8: Add Debounce to Lookup Hooks (~25 min)
**Files:** `src/hooks/useGeocode.ts`, `src/hooks/useDestinationStation.ts`
**Problem:** Both hooks fire a network request on every `destination` string change. Typing "Graz Hbf" sends 8 requests.
**Pattern — wrap the fetch in a debounced `setTimeout`:**
```ts
// In useDestinationStation.ts (useGeocode.ts same pattern):
useEffect(() => {
if (!destination.trim()) return;
let isMounted = true;
let abortController: AbortController | null = null;
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
abortController = new AbortController();
try {
// ... existing fetch logic, pass { signal: abortController.signal } ...
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (!isMounted) return;
setError(err instanceof Error ? err.message : "Station lookup failed");
setLoading(false);
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
abortController?.abort();
};
}, [destination]);
```
**400 ms** is the sweet spot: fast enough to feel responsive, slow enough to cancel in-flight requests from partial input.
---
#### Step 9: Pre-Group Calendar Events by Date (~20 min)
**File:** `src/app/calendar/CalendarView.tsx`
**Problem:** `renderCells` filters the entire events array for each day cell (~31-42 cells). With N events and M cells, that's O(N×M) `new Date()` allocations and comparisons per render.
**Fix — build a Map once with `useMemo`:**
```ts
// Inside CalendarView component, before renderCells:
const eventsByDate = React.useMemo(() => {
const map = new Map<string, Event[]>();
for (const event of events) {
const key = new Date(event.eventTime).toISOString().slice(0, 10); // "YYYY-MM-DD"
const existing = map.get(key);
if (existing) {
existing.push(event);
} else {
map.set(key, [event]);
}
}
return map;
}, [events]);
// Inside renderCells, replace the filter:
const dayKey = day.toISOString().slice(0, 10);
const dayEvents = eventsByDate.get(dayKey) ?? [];
```
This reduces per-cell cost from O(N) to O(1) and eliminates redundant `new Date()` construction.
---
#### Step 10: Add Dark Mode Toggle (~20 min)
**Files:** `src/hooks/useTheme.ts` (new), `src/app/layout/Header.tsx`
**Create `src/hooks/useTheme.ts`:**
```ts
"use client";
import { useState, useEffect, useCallback } from "react";
const THEME_KEY = "ttl_theme";
function getSystemTheme(): "dark" | "light" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function useTheme() {
const [dark, setDark] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(THEME_KEY);
const theme = stored ?? getSystemTheme();
const isDark = theme === "dark";
setDark(isDark);
document.documentElement.classList.toggle("dark", isDark);
}, []);
const toggle = useCallback(() => {
setDark((prev) => {
const next = !prev;
localStorage.setItem(THEME_KEY, next ? "dark" : "light");
document.documentElement.classList.toggle("dark", next);
return next;
});
}, []);
return { dark, toggle };
}
```
**Add to `Header.tsx`:**
```tsx
import { useTheme } from "@/hooks/useTheme";
const { dark, toggle } = useTheme();
// In the header JSX, next to the Add Event button:
<button
onClick={toggle}
aria-label="Toggle dark mode"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
{dark ? "☀️" : "🌙"}
</button>
```
---
#### Step 11: Fix Bike Route Steps (~5 min)
**File:** `src/lib/bike-routing-client.ts`
**Problem:** The `BikeRoutingClient` doesn't pass `steps=true` to OSRM, so the steps array is always empty.
**Add `steps` parameter to the query in `getBikeRoute`:**
```ts
const res = await this.client.get<OsrmResponse>(
path,
{ overview: "false", steps: "true" }, // ← add steps: "true"
{ cacheKey, ttl: this.defaultTtlMs },
);
```
---
### Phase 4 — Monitoring & Testing (~1 hour)
---
#### Step 12: Add Correlation IDs to API Errors (~15 min)
**Files:** All `src/app/api/*/route.ts`
**Add to each route's error handler:**
```ts
import { randomUUID } from "crypto";
// In catch blocks:
} catch (error) {
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] API error:`, error);
return NextResponse.json(
{ error: "Internal server error", correlationId: corrId },
{ status: 500 },
);
}
```
This lets the frontend display the correlation ID to users so they can report it when filing bugs.
---
#### Step 13: Add Hook Tests (~30 min)
**Files:** `src/hooks/__tests__/useJourneys.test.ts`, `src/hooks/__tests__/useBikeRoute.test.ts`
**Pattern — mock `global.fetch` and test state transitions:**
```ts
// src/hooks/__tests__/useJourneys.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { useJourneys } from "../useJourneys";
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns journeys when API succeeds", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
svcResL: [{
res: {
outConL: [{
ctxRecon: "test-journey",
secL: [
{ dep: { dTimeS: "20250101120000", dTimeR: "20250101120000" } },
{ arr: { aTimeS: "20250101130000", aTimeR: "20250101130000" } },
],
}],
},
}],
}),
});
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00"), 0)
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.journeys).toHaveLength(1);
expect(result.current.journeys[0].id).toBe("test-journey");
});
it("sets error when API fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
});
const { result } = renderHook(() =>
useJourneys("ext1", "ext2", new Date(), 0)
);
await waitFor(() => expect(result.current.error).toBeTruthy());
});
```
---
#### Step 14: Add Component Tests (~15 min)
**Files:** `src/app/event/__tests__/EventCard.test.tsx`, `src/app/calendar/__tests__/CalendarView.test.tsx`
**Pattern — render with mock data, verify key elements:**
```tsx
// src/app/event/__tests__/EventCard.test.tsx
import { render, screen } from "@testing-library/react";
import EventCard from "../EventCard";
const mockEvent = {
id: "test-1",
title: "Team Meeting",
destination: "Wien Hbf",
eventTime: new Date("2025-12-01T14:00:00"),
source: "manual" as const,
};
const mockStation = { name: "Graz Hbf", extId: "0WB0F0000600" };
it("renders event title and destination", () => {
render(<EventCard event={mockEvent} originStation={mockStation} />);
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Wien Hbf")).toBeInTheDocument();
});
```
---
## 4. Dependencies
No new dependencies required. All changes use existing packages:
| Package | Used For |
|---------|----------|
| `next` | API routes, SSR, App Router |
| `react` / `react-dom` | Components, hooks |
| `date-fns` | Calendar date manipulation |
| `node-ical` | ICS parsing |
| `vitest` + `@testing-library/react` | Tests |
| `@testing-library/jest-dom` | Test matchers |
---
## 5. Estimated Effort
| Phase | Time | Steps |
|-------|------|-------|
| 1 — Unblock Runtime | ~45 min | 3 (SSR fix, calendar wiring, HAFAS validation) |
| 2 — Deduplicate Code | ~2 hours | 4 (HAFAS dedup, wire clients, dead code, test setup) |
| 3 — Performance & UX | ~1.5 hours | 4 (debounce, calendar perf, dark mode, bike steps) |
| 4 — Monitoring & Testing | ~1 hour | 3 (correlation IDs, hook tests, component tests) |
| **Total** | **~5 hours** | **14 steps** |