diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 77d9517..0c2ea56 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router"; import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; import { useYjs, type YjsState } from "~/lib/use-yjs"; -import { useRouting } from "~/lib/use-routing"; +import { useRouting, type RouteError } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; import { SaveToJournalButton } from "~/components/SaveToJournalButton"; @@ -25,26 +25,32 @@ const ElevationChart = lazy(() => interface Toast { id: number; message: string; + variant?: "error" | "info"; } let toastIdCounter = 0; -function useAwarenessToasts(yjs: YjsState | null, t: TFunction) { +function useToasts() { const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message: string, variant: Toast["variant"] = "info") => { + const id = ++toastIdCounter; + setToasts((prev) => [...prev, { id, message, variant }]); + setTimeout(() => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, 4000); + }, []); + + return { toasts, addToast }; +} + +function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (message: string) => void) { const namesCacheRef = useRef>(new Map()); const initializedRef = useRef(false); useEffect(() => { if (!yjs) return; - const addToast = (message: string) => { - const id = ++toastIdCounter; - setToasts((prev) => [...prev, { id, message }]); - setTimeout(() => { - setToasts((prev) => prev.filter((toast) => toast.id !== id)); - }, 3000); - }; - const handleChange = ( changes: { added: number[]; updated: number[]; removed: number[] }, ) => { @@ -100,9 +106,7 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) { initializedRef.current = false; namesCacheRef.current.clear(); }; - }, [yjs, t]); - - return toasts; + }, [yjs, t, addToast]); } function ColorModeToggle({ yjs }: { yjs: YjsState }) { @@ -181,9 +185,23 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints); - const { computing, routeStats, requestRoute } = useRouting(yjs); + const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); - const toasts = useAwarenessToasts(yjs, t); + const { toasts, addToast } = useToasts(); + useAwarenessToasts(yjs, t, addToast); + + // Show toast on route calculation error + const prevErrorRef = useRef(null); + useEffect(() => { + if (routeError && routeError !== prevErrorRef.current) { + const message = + routeError === "rate_limit" ? t("rateLimitExceeded") : + routeError === "no_route" ? t("noRouteFound") : + t("routeError"); + addToast(message, "error"); + } + prevErrorRef.current = routeError; + }, [routeError, addToast, t]); const handleElevationHover = useCallback((pos: [number, number] | null) => { setHighlightPosition(pos); @@ -258,7 +276,9 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, {toasts.map((toast) => (
{toast.message}
diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index 4b24a34..b772c3c 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -61,11 +61,21 @@ export async function computeRoute(request: RouteRequest): Promise> { const response = await fetch(url); if (!response.ok) { const body = await response.text(); - throw new Error(`BRouter error (${response.status}): ${body}`); + throw new BRouterError(body.trim(), response.status); } return response.json() as Promise>; } diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index 0e1e4df..3283da5 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -21,11 +21,36 @@ function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] })); } +function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: React.RefObject) { + restoringRef.current = true; + yjs.doc.transact(() => { + for (let i = 0; i < snapshot.length && i < yjs.waypoints.length; i++) { + const yMap = yjs.waypoints.get(i); + const wp = snapshot[i]!; + if (yMap) { + yMap.set("lat", wp.lat); + yMap.set("lon", wp.lon); + } + } + // Remove extra waypoints added since snapshot + if (yjs.waypoints.length > snapshot.length) { + yjs.waypoints.delete(snapshot.length, yjs.waypoints.length - snapshot.length); + } + }); + // Reset after microtask so Yjs observers fire first + queueMicrotask(() => { restoringRef.current = false; }); +} + +export type RouteError = "no_route" | "failed" | "rate_limit" | null; + export function useRouting(yjs: YjsState | null) { const [isHost, setIsHost] = useState(false); const [computing, setComputing] = useState(false); + const [routeError, setRouteError] = useState(null); const [routeStats, setRouteStats] = useState({}); const debounceTimer = useRef>(undefined); + const lastGoodWaypointsRef = useRef(null); + const restoringRef = useRef(false); // Host election via Yjs awareness useEffect(() => { @@ -56,6 +81,9 @@ export function useRouting(yjs: YjsState | null) { points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], })).filter((a) => a.points.length >= 3); + // Save current waypoints so we can restore on failure + const snapshotBeforeCompute = getWaypointsFromYjs(yjs.waypoints); + setComputing(true); try { const response = await fetch("/api/route", { @@ -69,13 +97,22 @@ export function useRouting(yjs: YjsState | null) { }); if (response.status === 429) { - console.warn("[Rate Limit] Route computation rate limit exceeded"); + setRouteError("rate_limit"); + restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); + return; + } + if (!response.ok) { + const body = await response.json().catch(() => ({})); + const code = (body as { code?: string }).code; + setRouteError(code === "no_route" ? "no_route" : "failed"); + restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); return; } - if (!response.ok) return; const enriched = await response.json(); + setRouteError(null); + lastGoodWaypointsRef.current = snapshotBeforeCompute; setRouteStats({ distance: enriched.totalLength || undefined, elevationGain: enriched.totalAscend || undefined, @@ -91,7 +128,8 @@ export function useRouting(yjs: YjsState | null) { } }); } catch { - // Route computation failed + setRouteError("failed"); + restoreWaypoints(yjs, lastGoodWaypointsRef.current ?? snapshotBeforeCompute, restoringRef); } finally { setComputing(false); } @@ -101,7 +139,7 @@ export function useRouting(yjs: YjsState | null) { const requestRoute = useCallback( (waypoints: WaypointData[]) => { - if (!isHost) return; + if (!isHost || restoringRef.current) return; if (debounceTimer.current) clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(() => computeRoute(waypoints), 500); }, @@ -139,5 +177,5 @@ export function useRouting(yjs: YjsState | null) { }; }, [yjs, isHost, requestRoute]); - return { isHost, computing, routeStats, requestRoute }; + return { isHost, computing, routeError, routeStats, requestRoute }; } diff --git a/apps/planner/app/routes/api.route.ts b/apps/planner/app/routes/api.route.ts index 4ab4899..7f7e025 100644 --- a/apps/planner/app/routes/api.route.ts +++ b/apps/planner/app/routes/api.route.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.route"; -import { computeRoute } from "~/lib/brouter"; +import { computeRoute, BRouterError } from "~/lib/brouter"; import { checkRateLimit } from "~/lib/rate-limit"; export async function action({ request }: Route.ActionArgs) { @@ -40,7 +40,11 @@ export async function action({ request }: Route.ActionArgs) { headers: { "X-RateLimit-Remaining": String(limit.remaining) }, }); } catch (e) { + if (e instanceof BRouterError && e.statusCode >= 400 && e.statusCode < 500) { + // BRouter client error: unroutable waypoints, missing tiles, etc. + return data({ error: e.message, code: "no_route" }, { status: 422 }); + } const message = e instanceof Error ? e.message : "Route computation failed"; - return data({ error: message }, { status: 502 }); + return data({ error: message, code: "server_error" }, { status: 502 }); } } diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 48ad53a..72080a3 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -22,6 +22,8 @@ export default { connecting: "Verbinde...", loadingMap: "Karte wird geladen...", computingRoute: "Route wird berechnet...", + routeError: "Routenberechnung fehlgeschlagen. Bitte versuche es erneut.", + noRouteFound: "Keine Route gefunden. Versuche den Wegpunkt auf einen Weg zu verschieben.", host: "Host", connected: "Verbunden", saveToJournal: "Im Journal speichern", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 838416e..51af6f7 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -22,6 +22,8 @@ export default { connecting: "Connecting...", loadingMap: "Loading map...", computingRoute: "Computing route...", + routeError: "Route calculation failed. Please try again.", + noRouteFound: "No route found. Try moving the waypoint to a road or path.", host: "Host", connected: "Connected", saveToJournal: "Save to Journal",