# 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(); 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: ``` --- #### 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( 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(); 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** |