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
This commit is contained in:
2026-05-10 03:09:38 +02:00
parent c869d4958e
commit 77b8c6db98
7 changed files with 651 additions and 491 deletions
+82 -19
View File
@@ -1,23 +1,86 @@
## Phase 9 — Fix Broken Tests (~1 hour) # TimeToLeave — Implementation Checklist
The API route tests were written against an earlier interface and will fail as-is. > **Source:** [REWRITE_PLAN.md](./REWRITE_PLAN.md)
> **Total:** 4 phases, 14 steps, ~5 hours estimated effort
| # | Item | ✅ | ✔️ | ---
|---|---|----|-|
| 53 | `geocode.test.ts`: change `?q=``?name=` to match the actual route parameter | [x] | [x] |
| 54 | `geocode.test.ts`: fix expected error strings (`"Failed to geocode location"``"Internal server error"` / `"No results found"`) | [x] | [x] |
| 55 | `bike-route.test.ts`: change `?start=` / `?end=``?fromLat=&fromLng=&toLat=&toLng=` to match actual route | [x] | [x] |
| 56 | `bike-route.test.ts`: fix expected error strings (`"Missing 'start' or 'end' parameter"``"Missing required parameters ..."` and `"Failed to fetch bike route"``"Internal server error"`) | [x] | [x] |
| 57 | `calendar-utils.test.ts` (`extractEvents`): replace hardcoded past dates (2020-01-01, 2023-01-01) with `vi.setSystemTime` + dates relative to the frozen clock so filters behave as expected | [x] | [x] |
## Phase 10Fix Architecture & Critical Bugs (~2 hours) ## Phase 1 — Unblock Runtime (~45 min)
| # | Item | ✅ | ✔️ | Fix bugs that crash the app or lose user data.
|---|---|----|-|
| 58 | `useJourneys.ts`: remove direct `HafasClient` instantiation; route all HAFAS calls through `/api/hafas` to prevent direct browser→HAFAS requests (CORS + IP leakage) | [x] | [x] | | Step | ✅ Implemented | ✔️ Reviewed | Notes |
| 59 | `useBikeRoute.ts`: remove direct `BikeRoutingClient` instantiation; call `/api/bike-route` instead so OSRM is never contacted directly from the browser | [x] | [x] | |------|:--------------:|:-----------:|-------|
| 60 | `useOriginStation.ts`: use `location.coords.latitude` / `longitude` in the station search instead of the hardcoded `"Bahnhof"` query; use a HAFAS nearby-station lookup or geocode → nearest-station fallback | [x] | [x] | | **Step 1: Fix SSR Crash in `useBikeRoute`** (~10 min) | [x] | [x] | Relative URL fetch replaces `window.location.href` — SSR-safe. `isMounted` guard intact. |
| 61 | `hafas-client.ts` `parseHafasTime`: replace `new Date(y, mo, d, h, m, s)` (local TZ) with Vienna-timezone-aware construction — use `Intl` or a fixed UTC offset — so departure/arrival times are correct when the server is not in CET/CEST | [x] | [x] | | **Step 2: Wire Calendar Events into `EventsStore`** (~15 min) | [x] | [x] | `CalendarPanel.tsx` merges via `useEffect` when calendar events arrive. `mergeEvents()` converts `CalendarEvent` (string `eventTime`) → `Event` (Date `eventTime`). Bonus: localStorage persistence with rehydration. |
| 62 | `api/calendar/route.ts` and `api/calendar/parse/route.ts`: replace the inlined parsing logic with calls to `extractEvents()` from `calendar-utils.ts` so `cleanLocation()` and location-presence filtering are applied consistently | [x] | [x] | | **Step 3: Add Input Validation to `/api/hafas`** (~20 min) | [x] | [x] | Validates `svcReqL` array shape, method allowlist (TripSearch/LocMatch), caps `numF` at 10. Extra type guard on `svcReq.meth` (`typeof svcReq.meth !== 'string'`) exceeds spec. |
| 63 | `useBikeRoute.ts:18`: replace `if (!fromLat || !fromLng || !toLat || !toLng)` with `!= null` checks so coordinates at `0` (valid) are not skipped | [x] | [x] |
| 64 | Move `HafasClient` / `GeocodingClient` / `BikeRoutingClient` instances to module scope (or a shared context) so the in-instance caches in `GeocodingClient` survive across renders | [x] | [x] | ---
## 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)
- Move `parseHafasJourneys` from `useJourneys.ts` into `hafas-client.ts` and export it; import from `useJourneys.ts` (Option A — minimal risk)
- Files: `src/lib/hafas-client.ts`, `src/hooks/useJourneys.ts`
- [ ] **Step 5: Wire API Routes to Use Library Clients** (~30 min)
- Replace raw `fetch()` in `api/geocode/route.ts` with `GeocodingClient`, and in `api/bike-route/route.ts` with `BikeRoutingClient` (module-level singleton, proper error handling)
- Files: `src/app/api/geocode/route.ts`, `src/app/api/bike-route/route.ts`
- [ ] **Step 6: Remove Dead Code** (~5 min)
- Delete `src/lib/live-status-utils.ts` entirely; remove its export from `src/lib/index.ts`
- File: `src/lib/live-status-utils.ts`, `src/lib/index.ts`
- [ ] **Step 7: Create Missing Test Setup File** (~10 min)
- Create `src/test/setup.ts` with `import "@testing-library/jest-dom/vitest"` so jsdom matchers are registered globally
- File: `src/test/setup.ts`
---
## Phase 3 — Performance & UX (~1.5 hours)
- [ ] **Step 8: Add Debounce to Lookup Hooks** (~25 min)
- Wrap fetch in a 400 ms `setTimeout` with `AbortController` cleanup in `useGeocode.ts` and `useDestinationStation.ts` to prevent per-keystroke API calls
- Files: `src/hooks/useGeocode.ts`, `src/hooks/useDestinationStation.ts`
- [ ] **Step 9: Pre-Group Calendar Events by Date** (~20 min)
- Build a `Map<string, Event[]>` keyed by `YYYY-MM-DD` via `useMemo` in `CalendarView.tsx`; replace per-cell `filter()` with O(1) map lookup
- File: `src/app/calendar/CalendarView.tsx`
- [ ] **Step 10: Add Dark Mode Toggle** (~20 min)
- Create `useTheme.ts` hook (persist to `localStorage`, respect `prefers-color-scheme`); add sun/moon toggle button to `Header.tsx`
- Files: `src/hooks/useTheme.ts` (new), `src/app/layout/Header.tsx`
- [ ] **Step 11: Fix Bike Route Steps** (~5 min)
- Add `steps: "true"` to the query params in `BikeRoutingClient.getBikeRoute()` so OSRM returns turn-by-turn steps
- File: `src/lib/bike-routing-client.ts`
---
## Phase 4 — Monitoring & Testing (~1 hour)
- [ ] **Step 12: Add Correlation IDs to API Errors** (~15 min)
- Generate a short UUID (`randomUUID().slice(0, 8)`) in each API route's catch block; log it server-side and include it in the JSON error response
- Files: all `src/app/api/*/route.ts`
- [ ] **Step 13: Add Hook Tests** (~30 min)
- Create `useJourneys.test.ts` and `useBikeRoute.test.ts` — mock `global.fetch`, test state transitions (loading → success, loading → error)
- Files: `src/hooks/__tests__/useJourneys.test.ts`, `src/hooks/__tests__/useBikeRoute.test.ts`
- [ ] **Step 14: Add Component Tests** (~15 min)
- Create `EventCard.test.tsx` and `CalendarView.test.tsx` — render with mock data, verify key elements are in the document
- Files: `src/app/event/__tests__/EventCard.test.tsx`, `src/app/calendar/__tests__/CalendarView.test.tsx`
---
## Summary
| Phase | Steps | Est. Time |
|-------|-------|-----------|
| 1 — Unblock Runtime | 13 | ~45 min |
| 2 — Deduplicate Code | 47 | ~2 hours |
| 3 — Performance & UX | 811 | ~1.5 hours |
| 4 — Monitoring & Testing | 1214 | ~1 hour |
| **Total** | **14** | **~5 hours** |
+509 -472
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -5,6 +5,31 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
// 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" ||
typeof svcReq.meth !== "string" ||
!allowedMethods.includes(svcReq.meth)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
}
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
svcReq.req.numF = 10;
}
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
+23 -7
View File
@@ -1,18 +1,34 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useCalendar } from "@/hooks/useCalendar";
import { useEventsStore } from "@/hooks/useEventsStore";
import UrlTab from "./UrlTab"; import UrlTab from "./UrlTab";
import FileTab from "./FileTab"; import FileTab from "./FileTab";
type CalendarPanelProps = { type CalendarPanelProps = {
onLoadCalendar: (url: string | File) => Promise<void>;
loading: boolean;
error: string | null;
className?: string; className?: string;
}; };
const CalendarPanel: React.FC<CalendarPanelProps> = ({ onLoadCalendar, loading, error, className = "" }) => { const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
const [activeTab, setActiveTab] = useState<"url" | "file">("url"); const [activeTab, setActiveTab] = useState<"url" | "file">("url");
const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar();
const { mergeEvents } = useEventsStore();
// Merge calendar events into global store whenever they change
useEffect(() => {
if (calendarEvents.length > 0) {
mergeEvents(calendarEvents);
}
}, [calendarEvents, mergeEvents]);
const handleLoadCalendar = async (urlOrFile: string | File) => {
if (typeof urlOrFile === "string") {
await fetchCalendarFromUrl(urlOrFile);
} else {
await parseCalendarFromFile(urlOrFile);
}
};
return ( return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}> <div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
@@ -41,9 +57,9 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ onLoadCalendar, loading,
</nav> </nav>
</div> </div>
{activeTab === "url" ? ( {activeTab === "url" ? (
<UrlTab onLoadCalendar={(url) => onLoadCalendar(url)} loading={loading} error={error} /> <UrlTab onLoadCalendar={(url) => handleLoadCalendar(url)} loading={loading} error={error} />
) : ( ) : (
<FileTab onLoadCalendar={(file) => onLoadCalendar(file)} loading={loading} error={error} /> <FileTab onLoadCalendar={(file) => handleLoadCalendar(file)} loading={loading} error={error} />
)} )}
</div> </div>
</div> </div>
+5
View File
@@ -5,6 +5,7 @@ import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation"; import { useOriginStation } from "@/hooks/useOriginStation";
import CalendarView from "./CalendarView"; import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents"; import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel";
export default function CalendarPage() { export default function CalendarPage() {
const { events } = useEventsStore(); const { events } = useEventsStore();
@@ -18,6 +19,10 @@ export default function CalendarPage() {
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p> <p className="text-gray-600 dark:text-gray-400">View and manage your events</p>
</div> </div>
<div className="mb-6">
<CalendarPanel />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} /> <CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
+3 -7
View File
@@ -23,13 +23,9 @@ export function useBikeRoute(
setError(null); setError(null);
try { try {
const url = new URL("/api/bike-route", window.location.href); const response = await fetch(
url.searchParams.set("fromLat", String(fromLat)); `/api/bike-route?fromLat=${fromLat}&fromLng=${fromLng}&toLat=${toLat}&toLng=${toLng}`,
url.searchParams.set("fromLng", String(fromLng)); );
url.searchParams.set("toLat", String(toLat));
url.searchParams.set("toLng", String(toLng));
const response = await fetch(url.toString());
if (!response.ok) { if (!response.ok) {
const errBody = await response.json().catch(() => ({})); const errBody = await response.json().catch(() => ({}));
+20 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react"; import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import type { Event } from "@/types"; import type { Event, CalendarEvent } from "@/types";
const STORAGE_KEY = "ttl_events"; const STORAGE_KEY = "ttl_events";
@@ -24,6 +24,7 @@ interface EventsContextType {
removeEvent: (id: string) => void; removeEvent: (id: string) => void;
clearEvents: () => void; clearEvents: () => void;
setEvents: (events: Event[]) => void; setEvents: (events: Event[]) => void;
mergeEvents: (events: CalendarEvent[]) => void;
} }
const EventsContext = createContext<EventsContextType | undefined>(undefined); const EventsContext = createContext<EventsContextType | undefined>(undefined);
@@ -58,8 +59,25 @@ export function EventsProvider({ children }: { children: ReactNode }) {
setEventsState(evts); setEventsState(evts);
}, []); }, []);
const mergeEvents = useCallback((calendarEvents: CalendarEvent[]) => {
const converted: Event[] = calendarEvents.map((e) => ({
id: e.id,
title: e.title,
destination: e.destination,
eventTime: new Date(e.eventTime),
source: e.source,
}));
setEventsState((prev) => {
const merged = new Map<string, Event>();
prev.forEach((event) => merged.set(event.id, event));
converted.forEach((event) => merged.set(event.id, event));
return Array.from(merged.values());
});
}, []);
return ( return (
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents }}> <EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
{children} {children}
</EventsContext.Provider> </EventsContext.Provider>
); );