From 77b8c6db9858f438b913668eab38cf941a27c38a Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sun, 10 May 2026 03:09:38 +0200 Subject: [PATCH] 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 --- CHECKLIST.md | 101 ++- REWRITE_PLAN.md | 949 +++++++++++++++-------------- src/app/api/hafas/route.ts | 25 + src/app/calendar/CalendarPanel.tsx | 30 +- src/app/calendar/page.tsx | 5 + src/hooks/useBikeRoute.ts | 10 +- src/hooks/useEventsStore.tsx | 22 +- 7 files changed, 651 insertions(+), 491 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index 8675896..7ac44e3 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -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] | \ No newline at end of file +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` 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** | diff --git a/REWRITE_PLAN.md b/REWRITE_PLAN.md index 1b8e4fc..1dd19c8 100644 --- a/REWRITE_PLAN.md +++ b/REWRITE_PLAN.md @@ -1,534 +1,571 @@ -# ÖBB Planner — Next.js Rewrite Plan +# TimeToLeave — Post-Rewrite Fix Plan -> **Status:** Planned -> **Created:** 2024 -> **Scope:** Full rewrite from CRA + Express to Next.js App Router with new features +> **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 | Files | -|-------|-----------|-------| -| Backend | Express.js + CORS + node-ical | `server/index.js` (~140 lines) | -| Frontend | Create React App (React 19) | `oebb-planner.jsx` (~700 lines monolithic component) | -| Tests | Jest + Supertest | `server/__tests__/*.test.js` | -| Build | Separate `npm start` for server + `react-scripts start` for frontend | Two independent processes | - -### Problems with current architecture - -- Two separate processes to manage -- Monolithic component (~700 lines) — no component breakdown -- CRA is deprecated; `react-scripts` is unmaintained -- No TypeScript -- Inline styles make theming/maintenance painful -- No proper state management pattern -- Duplication between `oebb-planner.jsx` and `oebb-planner-app/src/App.js` +| 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. New Features (Beyond Rewrite) +## 2. Findings Summary -### 2.1 Calendar View (`/calendar`) +### 🔴 Blocking (3 issues) -A browsable month view that lets you navigate between months, see events as badges on dates, and click into a day to see event + train details. +| # | 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 | -**How it works:** +### 🟡 Code Quality (6 issues) -- User imports calendar via the existing ICS flow -- Events are stored in React state / localStorage -- A month grid shows event dots on dates that have events -- Clicking a day shows that day's events with their train info -- Clicking an event scrolls to / expands the full event card +| # | 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 | -**UI wireframe:** +### 🟢 Minor (3 issues) -``` -┌──────────────────────┬──────────────────────┐ -│ ← November 2024 → │ [Day] [Week] [Month] │ -├──────────────────────┼──────────────────────┤ -│ Su Mo Tu We Th Fr Sa │ Thursday, 14 Nov │ -│ 1 2 3 4 5 6 7 │ │ -│ 8 9 10 11 12 13 14 │ ● 14:00 Graz Hbf │ -│ ● ● │ 🚂 Leave 13:10 │ -│ 15 16 17 18 19 20 21 │ 🚲 Leave 11:07 │ -│ ● ● │ │ -│ ... │ ● 17:00 Linz Hbf │ -│ │ 🚂 Leave 15:30 │ -│ │ 🚲 Leave 15:45 │ -└──────────────────────┴──────────────────────┘ +| # | 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}`, +); ``` -### 2.2 Bicycle Routing +**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. -**API choice: OSRM Public Demo Server** (`router.project-osrm.org`) +--- -- Free, no API key required, open source -- Supports `bicycle` profile -- Returns distance, duration, turn-by-turn steps -- For production: self-host or switch to OpenRouteService +#### Step 2: Wire Calendar Events into `EventsStore` (~15 min) -**Geocoding:** Nominatim (OpenStreetMap) to convert station/event location names to coordinates for routing. +**Files to read:** `src/app/calendar/UrlTab.tsx`, `src/app/calendar/FileTab.tsx` -**Data flow:** +**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. -``` -Origin station (name) → already have coords from geolocation -Destination station (name) → geocode via Nominatim → get coordinates - → OSRM bicycle route → distance + duration - → cache in TripData alongside train journeys +**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); ``` -### 2.3 Train vs Bicycle Comparison +**Key detail:** `CalendarEvent.eventTime` is an ISO string, `Event.eventTime` is a `Date`. The conversion must happen before storing. -Each event card shows both travel modes side by side: +--- -``` -┌───────────────────────────────────────────────────┐ -│ 🏔 Meeting in Graz │ -│ 📍 Graz Hbf · 14:00 · Thu, 14 Nov │ -├───────────────────────────────────────────────────┤ -│ 🚂 NEXT BEST TRAIN 🚲 BYCYCLE │ -│ RJX 5234 on time 42 km │ -│ DEP 13:22 ARR 14:35 2h 15min │ -│ PLAT 3 0 emissions │ -│ Leave by 13:10 Leave by 11:07 │ -└───────────────────────────────────────────────────┘ +#### 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; +} ``` --- -## 3. Target Architecture +### Phase 2 — Deduplicate Code (~2 hours) -``` -TimeToLeave/ -├── package.json -├── next.config.ts -├── tsconfig.json -├── postcss.config.mjs ← Tailwind v4 uses @tailwindcss/postcss; no tailwind.config.ts needed -├── vitest.config.ts ← must exist before Phase 7; deferred from Phase 1 -├── src/ -│ ├── app/ -│ │ ├── layout.tsx ← root layout (fonts, providers, navbar) -│ │ ├── page.tsx ← main planner page (event cards) -│ │ ├── globals.css -│ │ ├── calendar/ -│ │ │ └── page.tsx ← browsable calendar view (NEW) -│ │ ├── api/ -│ │ │ ├── hafas/ -│ │ │ │ └── route.ts ← POST → proxy to ÖBB HAFAS -│ │ │ ├── calendar/ -│ │ │ │ ├── route.ts ← GET → fetch remote ICS calendar -│ │ │ │ └── parse/ -│ │ │ │ └── route.ts ← POST → parse raw ICS body -│ │ │ ├── geocode/ -│ │ │ │ └── route.ts ← GET → Nominatim geocoding (NEW) -│ │ │ ├── bike-route/ -│ │ │ │ └── route.ts ← GET → OSRM bicycle routing (NEW) -│ │ │ └── health/ -│ │ │ └── route.ts ← GET → liveness check -│ │ └── not-found.tsx -│ ├── components/ -│ │ ├── layout/ -│ │ │ ├── Header.tsx ← logo, server status, clock -│ │ │ └── Navbar.tsx ← navigation between pages (NEW) -│ │ ├── calendar/ -│ │ │ ├── CalendarPanel.tsx ← collapsible import panel -│ │ │ ├── CalendarView.tsx ← month grid component (NEW) -│ │ │ ├── DayEvents.tsx ← events for a selected day (NEW) -│ │ │ ├── UrlTab.tsx -│ │ │ └── FileTab.tsx -│ │ ├── event/ -│ │ │ ├── EventCard.tsx ← single event card (with train+bike) -│ │ │ ├── TrainSection.tsx ← train data in event card -│ │ │ ├── BikeSection.tsx ← bicycle data in event card (NEW) -│ │ │ ├── JourneyList.tsx ← all departures table -│ │ │ └── LeaveByBadge.tsx -│ │ ├── add-event/ -│ │ │ └── AddEventModal.tsx -│ │ └── ui/ -│ │ ├── Chip.tsx -│ │ ├── Button.tsx -│ │ └── LoadingSpinner.tsx -│ ├── hooks/ -│ │ ├── useServerHealth.ts ← polls /api/health every 30s -│ │ ├── useClock.ts ← ticking clock (every 10s) -│ │ ├── useGeolocation.ts ← GPS positioning -│ │ ├── useOriginStation.ts ← finds nearest ÖBB station -│ │ ├── useJourneys.ts ← fetches + caches train data -│ │ ├── useBikeRoute.ts ← fetches bicycle route (NEW) -│ │ ├── useCalendar.ts ← calendar import logic -│ │ └── useEventsStore.ts ← shared events state (NEW) -│ ├── lib/ -│ │ ├── hafas-client.ts ← HAFAS API client (class HafasClient) -│ │ ├── calendar-utils.ts ← ICS helpers (extractEvents, cleanLocation) -│ │ ├── countdown-utils.ts ← calculateCountdown, leaveBy helpers -│ │ ├── formatting.ts ← formatTime, formatDate, formatDateTime, formatDuration, formatDistance -│ │ ├── demo.ts ← demo journey generator -│ │ ├── constants.ts ← HAFAS_URL, NOMINATIM_URL, OSRM_URL, HAFAS_TIMEOUT_MS, DEFAULT_DAYS, APP_VERSION -│ │ ├── status-utils.ts ← StatusUtils.checkServerStatus() (used by useServerHealth) -│ │ ├── live-status-utils.ts ← LiveStatusUtils.getLiveStatus() (stub; live data TBD) -│ │ ├── geocoding-client.ts ← Nominatim client (NEW) -│ │ ├── bike-routing-client.ts ← OSRM client (NEW) -│ │ └── index.ts ← re-exports all lib modules -│ └── types/ -│ └── index.ts ← all TypeScript interfaces -├── src/lib/__tests__/ ← unit tests live alongside lib, not at repo root -│ ├── hafas-client.test.ts -│ ├── geocoding-client.test.ts -│ ├── calendar-utils.test.ts -│ ├── countdown-utils.test.ts -│ └── bike-routing-client.test.ts ← NEW -├── src/app/api/__tests__/ ← API route tests -│ ├── hafas.test.ts -│ ├── calendar.test.ts -│ ├── geocode.test.ts ← NEW -│ ├── bike-route.test.ts ← NEW -│ └── health.test.ts -├── public/ -│ ├── favicon.ico -│ └── robots.txt -├── .env.example -└── README.md -``` +Eliminate duplicated logic so each integration has one source of truth. --- -## 4. TypeScript Types +#### Step 4: Consolidate HAFAS Journey Parsing (~40 min) -### `src/types/index.ts` +**Files:** `src/lib/hafas-client.ts`, `src/hooks/useJourneys.ts` -```typescript -// ── HAFAS / Train ─────────────────────────────────────────── +**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). -interface Journey { - id: string; - sD: Date; // scheduled departure - rD: Date; // real departure - sA: Date; // scheduled arrival - rA: Date; // real arrival - delay: number; // minutes - platform: string; - changes: number; - trains: string[]; - cancelled: boolean; -} +**Option A — Export shared parser (minimal risk, recommended):** -interface Station { - name: string; - extId: string; -} +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`. -// ── Events ─────────────────────────────────────────────────── +**Option B — Full refactor (more work, cleaner long-term):** -interface Event { - id: string; - title: string; - destination: string; - eventTime: Date; - source: "manual" | "calendar"; -} +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. -// ── Trip Data (per event) ──────────────────────────────────── - -interface TripDataEntry { - journeys: Journey[]; - destName: string; - demo: boolean; - loading: boolean; - bikeRoute?: BikeRoute | null; // NEW - bikeLoading?: boolean; // NEW - bikeError?: string | null; // NEW - destCoords?: { lat: number; lng: number }; // NEW (cached geocode) -} - -// ── Bicycle Routing (NEW) ──────────────────────────────────── - -interface BikeRoute { - distance: number; // meters - duration: number; // seconds - steps?: BikeStep[]; -} - -interface BikeStep { - name: string; - distance: number; - duration: number; - instruction: string; -} - -// ── Geocoding (NEW) ────────────────────────────────────────── - -interface GeocodeResult { - lat: number; - lng: number; - display_name: string; -} - -// ── Calendar Import ────────────────────────────────────────── - -interface CalendarEvent { - id: string; - title: string; - destination: string; - eventTime: string; // ISO string from API - source: "calendar"; -} - -// ── UI Helpers ─────────────────────────────────────────────── - -interface CountdownInfo { - label: string; - color: string; - urgent: boolean; -} - -type ServerStatus = null | true | false; -type LiveStatus = null | true | false; -type LocState = "pending" | "granted" | "denied"; -type CalStatus = null | "loading" | "ok" | "error"; -``` +Start with **Option A** to eliminate duplication quickly. Pursue **Option B** later if the HAFAS response shape changes. --- -## 5. New API Routes +#### Step 5: Wire API Routes to Use Library Clients (~30 min) -### `POST /api/hafas` +**Files:** `src/app/api/geocode/route.ts`, `src/app/api/bike-route/route.ts` -- **Purpose:** Proxy to `fahrplan.oebb.at/bin/mgate.exe` -- **Body:** `{ svcReqL: [...] }` — HAFAS service requests -- **Returns:** HAFAS JSON response -- **Timeout:** 12s +**Problem:** `GeocodingClient` and `BikeRoutingClient` are fully implemented with retry + caching via `ApiClient`, but the API routes bypass them with raw `fetch()`. -### `GET /api/calendar?url=&days=14` +**Changes — `api/geocode/route.ts`:** -- **Purpose:** Fetch and parse a remote ICS calendar -- **Returns:** `CalendarEvent[]` -- **Supports:** `https://`, `webcal://` +```ts +import { GeocodingClient } from "@/lib/geocoding-client"; -### `POST /api/calendar/parse` +// Module-level singleton — cache persists across requests +const client = new GeocodingClient(); -- **Purpose:** Parse raw ICS content sent in request body -- **Body:** Raw ICS text -- **Returns:** `CalendarEvent[]` +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const name = searchParams.get("name"); + const countrycodes = searchParams.get("countrycodes"); -### `GET /api/geocode?name=Graz+Hbf&countrycodes=at` (NEW) - -- **Purpose:** Proxy to Nominatim for address→coordinates -- **Upstream:** `nominatim.openstreetmap.org/search?q={name}&format=json&limit=1&countrycodes=at` -- **Returns:** `{ lat, lng, display_name }` -- **Caching:** In-memory cache with TTL to avoid rate limits - -### `GET /api/bike-route?fromLat=...&fromLng=...&toLat=...&toLng=...` (NEW) - -- **Purpose:** Proxy to OSRM for bicycle routing -- **Upstream:** `router.project-osrm.org/route/v1/bicycle/{lon1},{lat1};{lon2},{lat2}?overview=false` -- **Returns:** `{ distance, duration, steps: [...] }` - -### `GET /api/health` - -- **Purpose:** Liveness check -- **Returns:** `{ ok: true, ts: ISOString, version: "2.0.0" }` - ---- - -## 6. Key Technical Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Framework | Next.js 15 App Router | Modern standard; API routes are serverless-compatible | -| Language | TypeScript (strict) | Type safety across frontend and backend | -| Styling | Tailwind CSS v4 | Replaces 700 lines of inline styles; v4 uses CSS-native config, no `tailwind.config.ts` | -| State | React hooks + Context | No Redux needed for this scale | -| API proxy | Next.js Route Handlers | Same logic as Express, no Express dependency | -| ICS parsing | Keep `node-ical` | Already works, well-tested | -| Bike routing | OSRM public demo | Free, no API key, good enough for dev | -| Geocoding | Nominatim | Free, open source, good Austria coverage | -| Testing | Vitest + Testing Library | Faster than Jest for TS, better DX | -| Deployment | Vercel (recommended) or any Node host | Single artifact | - ---- - -## 7. Migration Phases - -### Phase 1 — Scaffold Next.js Project (~30 min) - -1. Initialize Next.js 15 with App Router, TypeScript, Tailwind CSS -2. Set up `tsconfig.json` with strict mode -3. Create `.env.example` with `PORT`, `HAFAS_URL`, `NOMINATIM_URL`, `OSRM_URL` -4. Configure `next.config.js` (rewrites if needed) -5. Configure `vitest.config.ts` -6. Set up `postcss.config.mjs` - -### Phase 2 — Types + Library Layer (~2 hours) - -7. Define TypeScript types in `src/types/index.ts` -8. Port `lib/hafas-client.ts` — `HafasClient` class with `searchStation()` and `fetchJourneys()` -9. Port `lib/calendar-utils.ts` — `extractEvents()` + `cleanLocation()` (reusable in API routes AND tests) -10. Port `lib/countdown-utils.ts`, `lib/formatting.ts`, `lib/constants.ts`, `lib/demo.ts`, `lib/status-utils.ts`, `lib/live-status-utils.ts` -11. Create `lib/geocoding-client.ts` — Nominatim client (NEW) -12. Create `lib/bike-routing-client.ts` — OSRM client (NEW) - -### Phase 3 — API Routes (~45 min) - -13. `src/app/api/hafas/route.ts` — POST handler, same logic as Express -14. `src/app/api/calendar/route.ts` — GET handler for remote ICS -15. `src/app/api/calendar/parse/route.ts` — POST handler for ICS body -16. `src/app/api/geocode/route.ts` — GET handler for Nominatim (NEW) -17. `src/app/api/bike-route/route.ts` — GET handler for OSRM (NEW) -18. `src/app/api/health/route.ts` — health check - -### Phase 4 — Custom Hooks (~2.5 hours) - -19. `useServerHealth.ts` — polls `/api/health` every 30s -20. `useClock.ts` — interval that updates `now` every 10s -21. `useGeolocation.ts` — wraps `navigator.geolocation` -22. `useOriginStation.ts` — finds nearest station from geolocation -23. `useJourneys.ts` — the complex `fetchAll` logic, per-event journey fetching -24. `useBikeRoute.ts` — fetches bicycle route for an event (NEW) -25. `useCalendar.ts` — URL/file import with merge logic -26. `useEventsStore.ts` — shared events state via Context (NEW) - -### Phase 5 — UI Components (~3.5 hours) - -27. `ui/Chip.tsx` — small badge component -28. `ui/Button.tsx` — styled button -29. `ui/LoadingSpinner.tsx` — loading indicator -30. `event/LeaveByBadge.tsx` — countdown badge -31. `event/JourneyList.tsx` — departure rows -32. `event/TrainSection.tsx` — train data in event card -33. `event/BikeSection.tsx` — bicycle data in event card (NEW) -34. `event/EventCard.tsx` — composes train + bike sections -35. `calendar/UrlTab.tsx` -36. `calendar/FileTab.tsx` -37. `calendar/CalendarPanel.tsx` -38. `add-event/AddEventModal.tsx` -39. `layout/Header.tsx` -40. `layout/Navbar.tsx` (NEW) - -### Phase 6 — Calendar Page (~1 hour) - -41. `calendar/CalendarView.tsx` — month grid component (NEW) -42. `calendar/DayEvents.tsx` — events for a selected day (NEW) -43. `app/calendar/page.tsx` — calendar route (NEW) - -### Phase 7 — Tests (~2 hours) - -> Install test dependencies first: `npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom jsdom` and create `vitest.config.ts`. Update the `test` script in `package.json` to `vitest`. - -44. Migrate `server/__tests__/*.test.js` → `src/app/api/__tests__/*.test.ts` -45. Add unit tests for `src/lib/calendar-utils.ts`, `src/lib/countdown-utils.ts` in `src/lib/__tests__/` -46. Add API tests for `geocode` and `bike-route` in `src/app/api/__tests__/` (NEW) -47. Add component smoke tests with `@testing-library/react` _(optional)_ - -### Phase 8 — Cleanup (~30 min) - -48. Delete old `server/` directory -49. Delete old `oebb-planner-app/` directory -50. Delete `oebb-planner.jsx` -51. Update `README.md` with new architecture and instructions -52. Final integration test - ---- - -## 8. Dependencies - -### Runtime (installed) - -```json -{ - "dependencies": { - "next": "16.2.6", - "react": "19.2.4", - "react-dom": "19.2.4", - "node-ical": "^0.18.0" + if (!name) { + return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 }); } -} -``` -### Development (installed) + const results = await client.geocode(name, countrycodes || undefined); -```json -{ - "devDependencies": { - "typescript": "^5", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "tailwindcss": "^4", - "@tailwindcss/postcss": "^4", - "eslint": "^9", - "eslint-config-next": "16.2.6" + if (results.length === 0) { + return NextResponse.json({ error: "No results found" }, { status: 404 }); } + + return NextResponse.json(results[0]); } ``` -### To be installed before Phase 7 (tests) +**Changes — `api/bike-route/route.ts`:** -```json -{ - "devDependencies": { - "vitest": "^2.0.0", - "@vitejs/plugin-react": "^4.0.0", - "@testing-library/react": "^16.0.0", - "@testing-library/jest-dom": "^6.0.0", - "jsdom": "^25.0.0" +```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); } ``` -> Note: Tailwind v4 no longer requires `autoprefixer` or a separate `tailwind.config.ts` — configuration is done via CSS and `@tailwindcss/postcss`. +**Also add `steps=true` to `BikeRoutingClient.getBikeRoute`** (see Step 11). --- -## 9. Environment Variables +#### Step 6: Remove Dead Code (~5 min) -```env -# .env.example +**File:** `src/lib/live-status-utils.ts` -# Server port -PORT=3001 +Delete the file entirely. `LiveStatusUtils` returns `null` for everything and is never imported. -# ÖBB HAFAS API -HAFAS_URL=https://fahrplan.oebb.at/bin/mgate.exe - -# Nominatim geocoding (OpenStreetMap) -NOMINATIM_URL=https://nominatim.openstreetmap.org - -# OSRM bicycle routing -OSRM_URL=https://router.project-osrm.org - -# Nominatim user-agent / referer (required by their ToS) -NOMINATIM_USER_AGENT=OebbPlanner/1.0 +**Also:** Remove the export from `src/lib/index.ts`: +```ts +// REMOVE this line: +export * from "./live-status-utils"; ``` --- -## 10. Benefits of the Rewrite +#### Step 7: Create Missing Test Setup File (~10 min) -1. **Single process** — `npm run dev` starts everything -2. **Type safety** — TypeScript catches bugs at compile time -3. **Composability** — 700-line component becomes ~15 focused components -4. **Reusability** — `lib/` functions testable independently -5. **Deployable** — one artifact, works on Vercel / any Node host -6. **Maintainable** — hooks encapsulate side effects, components are pure UI -7. **Modern tooling** — no more CRA, no more `react-scripts eject` anxiety -8. **Bicycle routing** — complete picture of train vs bike for each event -9. **Calendar view** — browse events by date, not just a flat list -10. **Train+bike comparison** — side-by-side travel times in each event card +**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. --- -## 11. Estimated Effort +### Phase 3 — Performance & UX (~1.5 hours) -| Phase | Description | Est. Time | -|-------|-------------|-----------| -| 1 | Scaffold Next.js + Tailwind + TS | 30 min | -| 2 | Types + lib layer (incl. geocode, bike-route) | 2 hours | -| 3 | API routes (incl. geocode, bike-route) | 45 min | -| 4 | Custom hooks (incl. bike-route, events-store) | 2.5 hours | -| 5 | UI Components (incl. bike section, navbar) | 3.5 hours | -| 6 | Calendar page (incl. month view, day events) | 1 hour | -| 7 | Tests (API + lib + component) | 2 hours | -| 8 | Cleanup (delete old dirs, update README) | 30 min | -| **Total** | | **~13-14 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** | diff --git a/src/app/api/hafas/route.ts b/src/app/api/hafas/route.ts index d59ce90..a40ace1 100644 --- a/src/app/api/hafas/route.ts +++ b/src/app/api/hafas/route.ts @@ -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); diff --git a/src/app/calendar/CalendarPanel.tsx b/src/app/calendar/CalendarPanel.tsx index f060e31..c295b2c 100644 --- a/src/app/calendar/CalendarPanel.tsx +++ b/src/app/calendar/CalendarPanel.tsx @@ -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; - loading: boolean; - error: string | null; className?: string; }; -const CalendarPanel: React.FC = ({ onLoadCalendar, loading, error, className = "" }) => { +const CalendarPanel: React.FC = ({ 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 (
@@ -41,9 +57,9 @@ const CalendarPanel: React.FC = ({ onLoadCalendar, loading,
{activeTab === "url" ? ( - onLoadCalendar(url)} loading={loading} error={error} /> + handleLoadCalendar(url)} loading={loading} error={error} /> ) : ( - onLoadCalendar(file)} loading={loading} error={error} /> + handleLoadCalendar(file)} loading={loading} error={error} /> )} diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 4ae3127..5f9552a 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -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() {

View and manage your events

+
+ +
+
diff --git a/src/hooks/useBikeRoute.ts b/src/hooks/useBikeRoute.ts index 0f1f982..6fc1590 100644 --- a/src/hooks/useBikeRoute.ts +++ b/src/hooks/useBikeRoute.ts @@ -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(() => ({})); diff --git a/src/hooks/useEventsStore.tsx b/src/hooks/useEventsStore.tsx index ab289b3..1a11744 100644 --- a/src/hooks/useEventsStore.tsx +++ b/src/hooks/useEventsStore.tsx @@ -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(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(); + prev.forEach((event) => merged.set(event.id, event)); + converted.forEach((event) => merged.set(event.id, event)); + return Array.from(merged.values()); + }); + }, []); + return ( - + {children} );