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:
parent
6a18980d7e
commit
b9a77be020
6 changed files with 100 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue