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:
+82
-19
@@ -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 10 — Fix Architecture & Critical Bugs (~2 hours)
|
||||
## Phase 1 — Unblock Runtime (~45 min)
|
||||
|
||||
| # | Item | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 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] |
|
||||
| 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] |
|
||||
| 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] |
|
||||
| 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] |
|
||||
| 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] |
|
||||
Fix bugs that crash the app or lose user data.
|
||||
|
||||
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|
||||
|------|:--------------:|:-----------:|-------|
|
||||
| **Step 1: Fix SSR Crash in `useBikeRoute`** (~10 min) | [x] | [x] | Relative URL fetch replaces `window.location.href` — SSR-safe. `isMounted` guard intact. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
---
|
||||
|
||||
## 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 | 1–3 | ~45 min |
|
||||
| 2 — Deduplicate Code | 4–7 | ~2 hours |
|
||||
| 3 — Performance & UX | 8–11 | ~1.5 hours |
|
||||
| 4 — Monitoring & Testing | 12–14 | ~1 hour |
|
||||
| **Total** | **14** | **~5 hours** |
|
||||
|
||||
+509
-472
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,31 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
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 timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
||||
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
"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 FileTab from "./FileTab";
|
||||
|
||||
type CalendarPanelProps = {
|
||||
onLoadCalendar: (url: string | File) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
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 { 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 (
|
||||
<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>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import { useOriginStation } from "@/hooks/useOriginStation";
|
||||
import CalendarView from "./CalendarView";
|
||||
import DayEvents from "./DayEvents";
|
||||
import CalendarPanel from "./CalendarPanel";
|
||||
|
||||
export default function CalendarPage() {
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<CalendarPanel />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
|
||||
|
||||
@@ -23,13 +23,9 @@ export function useBikeRoute(
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
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());
|
||||
const response = await fetch(
|
||||
`/api/bike-route?fromLat=${fromLat}&fromLng=${fromLng}&toLat=${toLat}&toLng=${toLng}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EventsContextType {
|
||||
removeEvent: (id: string) => void;
|
||||
clearEvents: () => void;
|
||||
setEvents: (events: Event[]) => void;
|
||||
mergeEvents: (events: CalendarEvent[]) => void;
|
||||
}
|
||||
|
||||
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
||||
@@ -58,8 +59,25 @@ export function EventsProvider({ children }: { children: ReactNode }) {
|
||||
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 (
|
||||
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents }}>
|
||||
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
|
||||
{children}
|
||||
</EventsContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user