From 7abfddd607251b6eb04fd713b43889122d4aad66 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sun, 10 May 2026 03:39:30 +0200 Subject: [PATCH] Add debounce, theme toggle, and calendar optimization Implement 400ms debounce with AbortController in useGeocode and useDestinationStation to prevent excessive API calls. Add dark mode toggle via new useTheme hook, persisting preference to localStorage and applying to document root. Pre-group calendar events by date using a Map in CalendarView to replace O(n) filter operations with O(1) lookups. --- CHECKLIST.md | 42 +++++++++--------------------- src/app/calendar/CalendarView.tsx | 24 +++++++++++------ src/app/layout/Header.tsx | 9 +++++++ src/hooks/useDestinationStation.ts | 12 ++++++--- src/hooks/useGeocode.ts | 15 ++++++++--- src/hooks/useTheme.ts | 34 ++++++++++++++++++++++++ u00261 | 0 7 files changed, 91 insertions(+), 45 deletions(-) create mode 100644 src/hooks/useTheme.ts create mode 100644 u00261 diff --git a/CHECKLIST.md b/CHECKLIST.md index 5b62df5..d670810 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -21,41 +21,23 @@ Fix bugs that crash the app or lose user data. Eliminate duplicated logic so each integration has one source of truth. -- [x] **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` - -- [x] **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` - -- [x] **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` - -- [x] **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` +| Step | ✅ Implemented | ✔️ Reviewed | Notes | +|------|:------:|:------:|-------| +| **Step 4: Consolidate HAFAS Journey Parsing** (~40 min) | [x] | [x] | parseHafasJourneys moved to hafas-client.ts, exported, imported by useJourneys.ts. Option A followed. | +| **Step 5: Wire API Routes to Use Library Clients** (~30 min) | [x] | [x] | Both routes use module-level singleton clients. Param validation, error handling, and try/catch intact. | +| **Step 6: Remove Dead Code** (~5 min) | [x] | [x] | live-status-utils.ts deleted, export removed from index.ts. No remaining references. | +| **Step 7: Create Missing Test Setup File** (~10 min) | [x] | [x] | src/test/setup.ts created with jest-dom vitest import. Referenced correctly in vitest.config.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` +| Step | ✅ Implemented | ✔️ Reviewed | Notes | +|------|:------:|:------:|-------| +| **Step 8: Add Debounce to Lookup Hooks** (~25 min) | [x] | | 400ms setTimeout + AbortController in `useGeocode.ts` and `useDestinationStation.ts`. AbortError silently ignored. | +| **Step 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | | `useMemo` builds `Map` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. | +| **Step 10: Add Dark Mode Toggle** (~20 min) | [x] | | `useTheme.ts` created (localStorage + prefers-color-scheme). Sun/moon toggle button added to `Header.tsx`. | +| **Step 11: Fix Bike Route Steps** (~5 min) | [x] | | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. | --- diff --git a/src/app/calendar/CalendarView.tsx b/src/app/calendar/CalendarView.tsx index 048f6b2..5ac8ed5 100644 --- a/src/app/calendar/CalendarView.tsx +++ b/src/app/calendar/CalendarView.tsx @@ -14,6 +14,20 @@ type CalendarViewProps = { const CalendarView: React.FC = ({ events, onDateSelect, selectedDate, className = "" }) => { const [currentDate, setCurrentDate] = useState(selectedDate); + const eventsByDate = React.useMemo(() => { + const map = new Map(); + for (const event of events) { + const key = new Date(event.eventTime).toISOString().slice(0, 10); + const existing = map.get(key); + if (existing) { + existing.push(event); + } else { + map.set(key, [event]); + } + } + return map; + }, [events]); + const renderHeader = () => { return (
@@ -60,14 +74,8 @@ const CalendarView: React.FC = ({ events, onDateSelect, selec const cells: React.ReactNode[] = []; days.forEach((day, index) => { - const dayEvents = events.filter((event) => { - const eventDate = new Date(event.eventTime); - return ( - eventDate.getDate() === day.getDate() && - eventDate.getMonth() === day.getMonth() && - eventDate.getFullYear() === day.getFullYear() - ); - }); + const dayKey = day.toISOString().slice(0, 10); + const dayEvents = eventsByDate.get(dayKey) ?? []; const isCurrentMonth = isSameMonth(day, monthStart); const isTodayDate = isToday(day); diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx index 198e104..d356784 100644 --- a/src/app/layout/Header.tsx +++ b/src/app/layout/Header.tsx @@ -5,6 +5,7 @@ import Button from "@/app/ui/Button"; import AddEventModal from "@/app/add-event/AddEventModal"; import { useServerHealth } from "@/hooks/useServerHealth"; import { useEventsStore } from "@/hooks/useEventsStore"; +import { useTheme } from "@/hooks/useTheme"; type HeaderProps = { className?: string; @@ -14,6 +15,7 @@ const Header: React.FC = ({ className = "" }) => { const [showAddEventModal, setShowAddEventModal] = useState(false); const { status } = useServerHealth(); const { events } = useEventsStore(); + const { dark, toggle } = useTheme(); return (
@@ -39,6 +41,13 @@ const Header: React.FC = ({ className = "" }) => { +
diff --git a/src/hooks/useDestinationStation.ts b/src/hooks/useDestinationStation.ts index d737c0c..dabd40f 100644 --- a/src/hooks/useDestinationStation.ts +++ b/src/hooks/useDestinationStation.ts @@ -15,11 +15,14 @@ export function useDestinationStation(destination: string) { useEffect(() => { if (!destination.trim()) return; let isMounted = true; + let abortController: AbortController | null = null; - const fetchStation = async () => { + const timeoutId = setTimeout(async () => { setLoading(true); setError(null); + abortController = new AbortController(); + try { const body = { svcReqL: [ @@ -34,6 +37,7 @@ export function useDestinationStation(destination: string) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + signal: abortController.signal, }); if (!response.ok) { @@ -49,16 +53,18 @@ export function useDestinationStation(destination: string) { setStation(stations[0] ?? null); setLoading(false); } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; if (isMounted) { setError(err instanceof Error ? err.message : "Station lookup failed"); setLoading(false); } } - }; + }, 400); - fetchStation(); return () => { isMounted = false; + clearTimeout(timeoutId); + abortController?.abort(); }; }, [destination]); diff --git a/src/hooks/useGeocode.ts b/src/hooks/useGeocode.ts index 86055ee..a3e4ae3 100644 --- a/src/hooks/useGeocode.ts +++ b/src/hooks/useGeocode.ts @@ -8,13 +8,18 @@ export function useGeocode(destination: string) { useEffect(() => { if (!destination.trim()) return; let isMounted = true; + let abortController: AbortController | null = null; - const fetchCoords = async () => { + const timeoutId = setTimeout(async () => { setLoading(true); setError(null); + abortController = new AbortController(); + try { - const res = await fetch(`/api/geocode?name=${encodeURIComponent(destination)}`); + const res = await fetch(`/api/geocode?name=${encodeURIComponent(destination)}`, { + signal: abortController.signal, + }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `Geocoding failed with status ${res.status}`); @@ -25,16 +30,18 @@ export function useGeocode(destination: string) { setLoading(false); } } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; if (isMounted) { setError(err instanceof Error ? err.message : "Geocoding failed"); setLoading(false); } } - }; + }, 400); - fetchCoords(); return () => { isMounted = false; + clearTimeout(timeoutId); + abortController?.abort(); }; }, [destination]); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..9ed413a --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; + +const THEME_KEY = "ttl_theme"; + +function getServerTheme(): "dark" | "light" { + return "light"; +} + +function getClientTheme(): "dark" | "light" { + if (typeof window === "undefined") return getServerTheme(); + const stored = localStorage.getItem(THEME_KEY); + if (stored) return stored as "dark" | "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function useTheme() { + const [dark, setDark] = useState(() => getClientTheme() === "dark"); + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + }, [dark]); + + const toggle = useCallback(() => { + setDark((prev) => { + const next = !prev; + localStorage.setItem(THEME_KEY, next ? "dark" : "light"); + return next; + }); + }, []); + + return { dark, toggle }; +} diff --git a/u00261 b/u00261 new file mode 100644 index 0000000..e69de29