Add route error toasts with waypoint rollback

- Show error toasts when route calculation fails, with distinct messages:
  - "No route found" for BRouter 4xx (unroutable waypoints, missing tiles)
  - "Route calculation failed" for server errors / network failures
  - "Too many requests" for rate limiting
- Rollback all waypoints to last known good state on route failure
- Guard against restore→recompute loop via restoringRef flag
- Extract reusable useToasts hook from awareness toast logic
- Add BRouterError class to distinguish client vs server errors
- API returns 422 for unroutable requests instead of blanket 502

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-29 14:09:20 +02:00
parent 6a18980d7e
commit b9a77be020
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 100 additions and 24 deletions

View file

@ -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<Toast[]>([]);
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<Map<number, string>>(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<RouteError>(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) => (
<div
key={toast.id}
className="rounded-lg bg-gray-800 px-4 py-2 text-sm text-white shadow-lg animate-[fadeIn_0.2s_ease-out]"
className={`rounded-lg px-4 py-2 text-sm text-white shadow-lg animate-[fadeIn_0.2s_ease-out] ${
toast.variant === "error" ? "bg-red-600" : "bg-gray-800"
}`}
>
{toast.message}
</div>

View file

@ -61,11 +61,21 @@ export async function computeRoute(request: RouteRequest): Promise<EnrichedRoute
return mergeGeoJsonSegments(segments);
}
export class BRouterError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = "BRouterError";
}
}
async function fetchSegment(url: string): Promise<Record<string, unknown>> {
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<Record<string, unknown>>;
}

View file

@ -21,11 +21,36 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
}));
}
function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: React.RefObject<boolean>) {
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<RouteError>(null);
const [routeStats, setRouteStats] = useState<RouteStats>({});
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const lastGoodWaypointsRef = useRef<WaypointData[] | null>(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 };
}

View file

@ -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 });
}
}

View file

@ -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",

View file

@ -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",