rewrite phase 4
This commit is contained in:
+4
-4
@@ -57,11 +57,11 @@
|
||||
| 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 | [ ] | [ ] |
|
||||
| 22 | `useOriginStation.ts` — finds nearest station from geolocation | [x] | [x] |
|
||||
| 23 | `useJourneys.ts` — the complex `fetchAll` logic, per-event journey fetching | [x] | [x] |
|
||||
| 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) | [ ] | [ ] |
|
||||
| 25 | `useCalendar.ts` — URL/file import with merge logic | [x] | [x] |
|
||||
| 26 | `useEventsStore.ts` — shared events state via Context (NEW) | [x] | [x] |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user