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.
This commit is contained in:
2026-05-10 03:39:30 +02:00
parent 28c25c32ab
commit 7abfddd607
7 changed files with 91 additions and 45 deletions
+12 -30
View File
@@ -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<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`
| 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<string, Event[]>` 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. |
---
+16 -8
View File
@@ -14,6 +14,20 @@ type CalendarViewProps = {
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate, className = "" }) => {
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
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);
const existing = map.get(key);
if (existing) {
existing.push(event);
} else {
map.set(key, [event]);
}
}
return map;
}, [events]);
const renderHeader = () => {
return (
<div className="flex items-center justify-between mb-4">
@@ -60,14 +74,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({ 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);
+9
View File
@@ -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<HeaderProps> = ({ className = "" }) => {
const [showAddEventModal, setShowAddEventModal] = useState(false);
const { status } = useServerHealth();
const { events } = useEventsStore();
const { dark, toggle } = useTheme();
return (
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}>
@@ -39,6 +41,13 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}>
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>
</div>
</div>
</div>
+9 -3
View File
@@ -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]);
+11 -4
View File
@@ -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]);
+34
View File
@@ -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 };
}
View File