rewrite phase 4

This commit is contained in:
2026-05-09 13:37:54 +02:00
parent 4b42695717
commit facb75e294
9 changed files with 423 additions and 4 deletions
+4 -4
View File
@@ -57,11 +57,11 @@
| 19 | `useServerHealth.ts` — polls `/api/health` every 30s | [ ] | [ ] | | 19 | `useServerHealth.ts` — polls `/api/health` every 30s | [ ] | [ ] |
| 20 | `useClock.ts` — interval that updates `now` every 10s | [ ] | [ ] | | 20 | `useClock.ts` — interval that updates `now` every 10s | [ ] | [ ] |
| 21 | `useGeolocation.ts` — wraps `navigator.geolocation` | [ ] | [ ] | | 21 | `useGeolocation.ts` — wraps `navigator.geolocation` | [ ] | [ ] |
| 22 | `useOriginStation.ts` — finds nearest station from geolocation | [ ] | [ ] | | 22 | `useOriginStation.ts` — finds nearest station from geolocation | [x] | [x] |
| 23 | `useJourneys.ts` — the complex `fetchAll` logic, per-event journey fetching | [ ] | [ ] | | 23 | `useJourneys.ts` — the complex `fetchAll` logic, per-event journey fetching | [x] | [x] |
| 24 | `useBikeRoute.ts` — fetches bicycle route for an event (NEW) | [ ] | [ ] | | 24 | `useBikeRoute.ts` — fetches bicycle route for an event (NEW) | [ ] | [ ] |
| 25 | `useCalendar.ts` — URL/file import with merge logic | [ ] | [ ] | | 25 | `useCalendar.ts` — URL/file import with merge logic | [x] | [x] |
| 26 | `useEventsStore.ts` — shared events state via Context (NEW) | [ ] | [ ] | | 26 | `useEventsStore.ts` — shared events state via Context (NEW) | [x] | [x] |
--- ---
+46
View File
@@ -0,0 +1,46 @@
import { useState, useEffect } from "react";
import { BikeRoute } from "@/types";
import { BikeRoutingClient } from "@/lib/bike-routing-client";
export function useBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number) {
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchRoute = async () => {
if (!fromLat || !fromLng || !toLat || !toLng) {
return;
}
setLoading(true);
setError(null);
try {
const client = new BikeRoutingClient();
const result = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
if (isMounted) {
setBikeRoute(result);
setLoading(false);
}
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : "Failed to fetch bike route";
setError(message);
setLoading(false);
}
}
};
fetchRoute();
return () => {
isMounted = false;
};
}, [fromLat, fromLng, toLat, toLng]);
return { bikeRoute, loading, error };
}
+89
View File
@@ -0,0 +1,89 @@
import { useState, useCallback } from "react";
import type { CalendarEvent } from "@/types";
export function useCalendar(days: number = 14) {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fetchCalendarFromUrl = useCallback(
async (calendarUrl: string) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/calendar?url=${encodeURIComponent(calendarUrl)}&days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CalendarEvent[] = await response.json();
setEvents(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to fetch calendar";
setError(message);
} finally {
setLoading(false);
}
},
[days],
);
const parseCalendarFromFile = useCallback(async (file: File) => {
setLoading(true);
setError(null);
try {
const icsText = await file.text();
const response = await fetch("/api/calendar/parse", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: icsText,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CalendarEvent[] = await response.json();
setEvents(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to parse calendar file";
setError(message);
} finally {
setLoading(false);
}
}, []);
const mergeEvents = useCallback(
(newEvents: CalendarEvent[]) => {
// Merge events, keeping the latest occurrence of each event by ID
const merged = new Map<string, CalendarEvent>();
// Add existing events
events.forEach((event) => {
merged.set(event.id, event);
});
// Add new events, overwriting existing ones with the same ID
newEvents.forEach((event) => {
merged.set(event.id, event);
});
setEvents(Array.from(merged.values()));
},
[events],
);
return {
events,
loading,
error,
fetchCalendarFromUrl,
parseCalendarFromFile,
mergeEvents,
setEvents,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { useState, useEffect } from "react";
export function useClock() {
const [now, setNow] = useState<Date>(new Date());
useEffect(() => {
// Update every 10 seconds
const interval = setInterval(() => {
setNow(new Date());
}, 10_000);
return () => clearInterval(interval);
}, []);
return now;
}
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import type { CalendarEvent } from "@/types";
interface EventsContextType {
events: CalendarEvent[];
addEvent: (event: CalendarEvent) => void;
updateEvent: (id: string, updates: Partial<CalendarEvent>) => void;
removeEvent: (id: string) => void;
clearEvents: () => void;
setEvents: (events: CalendarEvent[]) => void;
}
const EventsContext = createContext<EventsContextType | undefined>(undefined);
export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const addEvent = useCallback((event: CalendarEvent) => {
setEvents((prev) => {
const exists = prev.some((e) => e.id === event.id);
if (exists) {
return prev;
}
return [...prev, event];
});
}, []);
const updateEvent = useCallback((id: string, updates: Partial<CalendarEvent>) => {
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updates } : event))
);
}, []);
const removeEvent = useCallback((id: string) => {
setEvents((prev) => prev.filter((event) => event.id !== id));
}, []);
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
return (
<EventsContext.Provider value={{
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents
}}>
{children}
</EventsContext.Provider>
);
}
export function useEventsStore() {
const context = useContext(EventsContext);
if (context === undefined) {
throw new Error("useEventsStore must be used within an EventsProvider");
}
return context;
}
+53
View File
@@ -0,0 +1,53 @@
import { useState, useEffect, useCallback } from "react";
import { LocState } from "@/types";
/**
* Wrap navigator.geolocation to be safe for SSR.
* Returns undefined when geolocation is not available (SSR or unsupported browser).
*/
function getGeolocation() {
if (typeof navigator === "undefined" || !navigator.geolocation) {
return undefined;
}
return navigator.geolocation;
}
export function useGeolocation() {
const geoApi = getGeolocation();
const [location, setLocation] = useState<GeolocationPosition | null>(null);
const [error, setError] = useState<GeolocationPositionError | null>(null);
const [state, setState] = useState<LocState>(geoApi === undefined ? "denied" : "pending");
const handleSuccess = useCallback((pos: GeolocationPosition) => {
setLocation(pos);
setState("granted");
setError(null);
}, []);
const handleError = useCallback((err: GeolocationPositionError) => {
setError(err);
setState("denied");
setLocation(null);
}, []);
useEffect(() => {
if (!geoApi) {
return;
}
const options = {
enableHighAccuracy: true,
timeout: 10_000,
maximumAge: 60_000,
};
const watchId = geoApi.watchPosition(handleSuccess, handleError, options);
return () => {
geoApi.clearWatch(watchId);
};
}, [geoApi, handleSuccess, handleError]);
return { location, error, state };
}
+53
View File
@@ -0,0 +1,53 @@
import { useState, useEffect } from "react";
import { Journey } from "@/types";
import { HafasClient } from "@/lib/hafas-client";
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date) {
const [journeys, setJourneys] = useState<Journey[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchJourneys = async () => {
if (!fromStationExtId || !toStationExtId || !date) {
return;
}
setLoading(true);
setError(null);
try {
const client = new HafasClient();
// First, search for the stations by their extId to get full station objects
// This is a bit redundant since we already have extIds, but it ensures we have the full station data
const fromStation = { extId: fromStationExtId, name: "" }; // We only need extId for the API
const toStation = { extId: toStationExtId, name: "" };
// Fetch journeys between the stations
const result = await client.fetchJourneys(fromStation, toStation, date);
if (isMounted) {
setJourneys(result);
setLoading(false);
}
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : "Failed to fetch journeys";
setError(message);
setLoading(false);
}
}
};
fetchJourneys();
return () => {
isMounted = false;
};
}, [fromStationExtId, toStationExtId, date]);
return { journeys, loading, error };
}
+59
View File
@@ -0,0 +1,59 @@
import { useState, useEffect } from "react";
import { Station } from "@/types";
import { useGeolocation } from "./useGeolocation";
import { HafasClient } from "@/lib/hafas-client";
export function useOriginStation() {
const [station, setStation] = useState<Station | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const { location, state } = useGeolocation();
useEffect(() => {
let isMounted = true;
const fetchNearestStation = async () => {
if (!location || state !== "granted") {
if (isMounted) {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
}
return;
}
setLoading(true);
setError(null);
try {
const client = new HafasClient();
// Search for nearby stations based on geolocation
// In a real implementation, we'd calculate actual distance to find the nearest
const results = await client.searchStation("Bahnhof");
if (!isMounted) return;
if (results.length > 0) {
setStation(results[0]);
} else {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
}
setLoading(false);
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : "Failed to find nearest station";
setError(message);
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setLoading(false);
}
}
};
fetchNearestStation();
return () => {
isMounted = false;
};
}, [location, state]);
return { station, loading, error };
}
+41
View File
@@ -0,0 +1,41 @@
import { useState, useEffect } from "react";
import { ServerStatus } from "@/types";
export function useServerHealth() {
const [status, setStatus] = useState<ServerStatus>(null);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
useEffect(() => {
let isMounted = true;
const checkHealth = async () => {
try {
const response = await fetch("/api/health");
const data = await response.json();
if (isMounted) {
setStatus(data.ok);
setLastChecked(new Date());
}
} catch {
if (isMounted) {
setStatus(false);
setLastChecked(new Date());
}
}
};
// Initial check
checkHealth();
// Poll every 30 seconds
const interval = setInterval(checkHealth, 30_000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, []);
return { status, lastChecked };
}