rewrite phase 4
This commit is contained in:
+4
-4
@@ -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] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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