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

17 KiB
Raw Permalink Blame History

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:

// 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.

// 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:

// 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:

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:

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:

// 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:

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:

// 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:

// 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:

"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:

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:

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:

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:

// 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:

// 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