From 34e7fe590c91431168b91134ecf6c0a69f7421af Mon Sep 17 00:00:00 2001 From: Kesar Sidhu Date: Fri, 19 Jun 2026 09:16:37 -0700 Subject: [PATCH 1/6] fix(results): anchor stop markers to lat/lng at all zoom levels Remove duplicate translate on Advanced Marker pin content (default anchor is already bottom-center) and always set scaledSize plus anchor on legacy Marker icons so stop pins stay pinned to their geographic coordinates when zooming. Co-authored-by: Cursor --- app/ui/src/app/results/components/Map.tsx | 31 ++++++++++------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/app/ui/src/app/results/components/Map.tsx b/app/ui/src/app/results/components/Map.tsx index 3ce831d9..7cf6129b 100644 --- a/app/ui/src/app/results/components/Map.tsx +++ b/app/ui/src/app/results/components/Map.tsx @@ -28,10 +28,18 @@ declare const process: { const DAVIS_CENTER = { lat: 38.5449, lng: -121.7405 }; const MARKER_ICON_WIDTH = 28; const MARKER_ICON_HEIGHT = 40; +const STOP_MARKER_ANCHOR = { + x: MARKER_ICON_WIDTH / 2, + y: MARKER_ICON_HEIGHT, +} as const; -function getMarkerScaledSize(): google.maps.Size | undefined { +function createStopMarkerIcon(iconUrl: string): google.maps.Icon | undefined { if (typeof google === "undefined") return undefined; - return new google.maps.Size(MARKER_ICON_WIDTH, MARKER_ICON_HEIGHT); + return { + url: iconUrl, + scaledSize: new google.maps.Size(MARKER_ICON_WIDTH, MARKER_ICON_HEIGHT), + anchor: new google.maps.Point(STOP_MARKER_ANCHOR.x, STOP_MARKER_ANCHOR.y), + }; } // fillColor is always a route palette hex from routeColorHex, never user input. @@ -44,8 +52,8 @@ function createRoutePinElement(fillColor: string): HTMLElement { const wrapper = document.createElement("div"); wrapper.style.width = `${MARKER_ICON_WIDTH}px`; wrapper.style.height = `${MARKER_ICON_HEIGHT}px`; - // Anchor bottom-center of the pin on the stop (tip is at y=40 in the SVG). - wrapper.style.transform = "translate(-50%, -100%)"; + // AdvancedMarkerElement anchors at bottom-center by default; do not apply an + // extra translate or the pin tip drifts by a fixed pixel offset when zooming. wrapper.style.filter = "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))"; const img = document.createElement("img"); @@ -645,7 +653,7 @@ export default function MapComponent({ routes.map((route, routeIndex) => { const accentColor = routeColorHex(routeIndex); const iconUrl = markerSvgDataUrl(accentColor); - const scaledSize = getMarkerScaledSize(); + const stopIcon = createStopMarkerIcon(iconUrl); const sorted = [...route.stops].sort( (a, b) => a.sequence - b.sequence, ); @@ -682,18 +690,7 @@ export default function MapComponent({ handleStopHover({ From 3f258d5743d6ea7e9d49808cc1bdbd54ee4f1d19 Mon Sep 17 00:00:00 2001 From: Kesar Sidhu Date: Fri, 19 Jun 2026 09:22:47 -0700 Subject: [PATCH 2/6] fix(results): remove mock route fallback on results page Load routes only from sessionStorage after optimize; show an error overlay when no stored data or corrupt JSON instead of silently rendering mock routes. Co-authored-by: Cursor --- app/ui/src/app/results/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/ui/src/app/results/page.tsx b/app/ui/src/app/results/page.tsx index 9e3bb9be..12e8e82e 100644 --- a/app/ui/src/app/results/page.tsx +++ b/app/ui/src/app/results/page.tsx @@ -69,10 +69,12 @@ function readInitialRoutes(): RouteLoadResult { } if (!stored) { - // No prior optimize run for this session (e.g. /results visited directly): there is - // nothing to show, so the page falls through to its normal empty-routes UI. cachedRouteLoadKey = cacheKey; - cachedRouteLoadResult = EMPTY_ROUTE_LOAD_RESULT; + cachedRouteLoadResult = { + routes: [], + error: + "No optimized routes found. Please run optimize from the edit page.", + }; return cachedRouteLoadResult; } From f410d5b7a86adf31e58df8736f2e8f15e3017957 Mon Sep 17 00:00:00 2001 From: Kesar Sidhu Date: Fri, 19 Jun 2026 09:40:30 -0700 Subject: [PATCH 3/6] fix(results): draw road-following polylines via DirectionsService Use callback-based directions requests with status checks, extract road paths from overview or leg steps, fetch routes sequentially to avoid rate limits, stop overwriting cached road paths with straight lines, and document Directions API requirement in .env.example. Co-authored-by: Cursor --- app/ui/.env.example | 1 + app/ui/src/app/results/components/Map.tsx | 216 ++++++++++++++-------- 2 files changed, 141 insertions(+), 76 deletions(-) diff --git a/app/ui/.env.example b/app/ui/.env.example index 71899d2c..55b12b0f 100644 --- a/app/ui/.env.example +++ b/app/ui/.env.example @@ -1,4 +1,5 @@ # Copy to .env.local and fill in your values (get key + Map ID from Google Cloud Console) +# Enable "Maps JavaScript API" and "Directions API" for the key used below. NEXT_PUBLIC_GOOGLE_MAPS_KEY= NEXT_PUBLIC_GOOGLE_MAPS_MAP_ID= # DELIVERYOPTIMIZER_API_URL= # Cloud Run URL — only needed in production (leave unset for local dev) diff --git a/app/ui/src/app/results/components/Map.tsx b/app/ui/src/app/results/components/Map.tsx index 7cf6129b..62f8d8e4 100644 --- a/app/ui/src/app/results/components/Map.tsx +++ b/app/ui/src/app/results/components/Map.tsx @@ -98,6 +98,52 @@ function routeCacheKey(path: google.maps.LatLngLiteral[]): string { return path.map((p) => `${p.lat.toFixed(6)},${p.lng.toFixed(6)}`).join("|"); } +function routePathsKey(routes: Route[]): string { + return routes + .map((route) => { + const path = buildRoutePath(route, null); + return `${route.vehicleId}:${routeCacheKey(path)}`; + }) + .join("|"); +} + +function requestDrivingDirections( + service: google.maps.DirectionsService, + request: google.maps.DirectionsRequest, +): Promise { + return new Promise((resolve, reject) => { + service.route(request, (result, status) => { + if (status === google.maps.DirectionsStatus.OK && result) { + resolve(result); + return; + } + reject(new Error(`Directions request failed: ${status}`)); + }); + }); +} + +/** Prefer overview_path; fall back to leg step paths when overview is missing. */ +function extractRoadPath( + result: google.maps.DirectionsResult, +): google.maps.LatLng[] { + const route = result.routes[0]; + if (!route) return []; + + if (route.overview_path && route.overview_path.length >= 2) { + return route.overview_path; + } + + const path: google.maps.LatLng[] = []; + for (const leg of route.legs ?? []) { + for (const step of leg.steps ?? []) { + if (step.path?.length) { + path.push(...step.path); + } + } + } + return path; +} + function buildRoutePath( route: Route, pendingPinMove: PendingPinMove | null, @@ -135,10 +181,22 @@ function RoutePolylinesOverlay({ {}, ); const directionsCacheRef = useRef(new Map()); + const onRouteDistanceUpdateRef = useRef(onRouteDistanceUpdate); + const routesRef = useRef(routes); + const routesPathKey = useMemo(() => routePathsKey(routes), [routes]); + + useEffect(() => { + routesRef.current = routes; + }, [routes]); + + useEffect(() => { + onRouteDistanceUpdateRef.current = onRouteDistanceUpdate; + }, [onRouteDistanceUpdate]); useEffect(() => { if (!map || typeof google === "undefined") return; + const routesSnapshot = routesRef.current; Object.values(polylinesByVehicleRef.current).forEach((p) => p.setMap(null)); polylinesByVehicleRef.current = {}; @@ -157,87 +215,96 @@ function RoutePolylinesOverlay({ polylinesByVehicleRef.current[route.vehicleId] = fallbackPoly; }; - void Promise.allSettled( - routes.map(async (route, routeIndex) => { - const strokeColor = routeColorHex(routeIndex); - const path = buildRoutePath(route, null); - if (path.length < 2) return; - const origin = path[0]!; - const destination = path[path.length - 1]!; - - const waypoints = path - .slice(1, -1) - .map((location) => ({ location, stopover: true })); - if (waypoints.length > 25) { - drawFallback(route, strokeColor); - return; - } - - const cacheKey = routeCacheKey(path); - const cached = directionsCacheRef.current.get(cacheKey); - if (cached && cached.path.length >= 2) { - if (cancelled) return; - const cachedPoly = new google.maps.Polyline({ - map, - path: cached.path, - ...routePolylineOptions(strokeColor), - }); - polylinesByVehicleRef.current[route.vehicleId] = cachedPoly; - if (cancelled) return; - if (cached.meters > 0 && onRouteDistanceUpdate) { - const distanceMi = Number((cached.meters / 1609.344).toFixed(1)); - onRouteDistanceUpdate(route.vehicleId, distanceMi); - } - return; - } + const drawRoutePolyline = async (route: Route, routeIndex: number) => { + const strokeColor = routeColorHex(routeIndex); + const path = buildRoutePath(route, null); + if (path.length < 2) return; - try { - const result = await directionsService.route({ - origin, - destination, - waypoints, - optimizeWaypoints: false, - travelMode: google.maps.TravelMode.DRIVING, - }); - if (cancelled) return; + const origin = path[0]!; + const destination = path[path.length - 1]!; + const waypoints = path + .slice(1, -1) + .map((location) => ({ location, stopover: true })); - const roadPath = result.routes[0]?.overview_path; - if (!roadPath || roadPath.length < 2) { - drawFallback(route, strokeColor); - return; - } + if (waypoints.length > 25) { + drawFallback(route, strokeColor); + return; + } - const totalMeters = (result.routes[0]?.legs ?? []).reduce( - (sum, leg) => sum + (leg.distance?.value ?? 0), - 0, - ); - if (cancelled) return; - if (totalMeters > 0 && onRouteDistanceUpdate) { - const distanceMi = Number((totalMeters / 1609.344).toFixed(1)); - onRouteDistanceUpdate(route.vehicleId, distanceMi); - } - if (cancelled) return; + const cacheKey = routeCacheKey(path); + const cached = directionsCacheRef.current.get(cacheKey); + if (cached && cached.path.length >= 2) { + if (cancelled) return; + const cachedPoly = new google.maps.Polyline({ + map, + path: cached.path, + ...routePolylineOptions(strokeColor), + }); + polylinesByVehicleRef.current[route.vehicleId] = cachedPoly; + if (cached.meters > 0 && onRouteDistanceUpdateRef.current) { + const distanceMi = Number((cached.meters / 1609.344).toFixed(1)); + onRouteDistanceUpdateRef.current(route.vehicleId, distanceMi); + } + return; + } - rememberDirections(directionsCacheRef.current, cacheKey, { - path: roadPath, - meters: totalMeters, - }); + try { + const result = await requestDrivingDirections(directionsService, { + origin, + destination, + waypoints, + optimizeWaypoints: false, + travelMode: google.maps.TravelMode.DRIVING, + }); + if (cancelled) return; - const roadPoly = new google.maps.Polyline({ - map, - path: roadPath, - ...routePolylineOptions(strokeColor), - }); - polylinesByVehicleRef.current[route.vehicleId] = roadPoly; - } catch (err) { + const roadPath = extractRoadPath(result); + if (roadPath.length < 2) { console.warn( - "[Map] DirectionsService failed, falling back to straight line:", - err, + `[Map] Directions returned no road path for vehicle ${route.vehicleId}; falling back to straight line.`, ); drawFallback(route, strokeColor); + return; } - }), - ); + + const totalMeters = (result.routes[0]?.legs ?? []).reduce( + (sum, leg) => sum + (leg.distance?.value ?? 0), + 0, + ); + if (cancelled) return; + + rememberDirections(directionsCacheRef.current, cacheKey, { + path: roadPath, + meters: totalMeters, + }); + + const roadPoly = new google.maps.Polyline({ + map, + path: roadPath, + ...routePolylineOptions(strokeColor), + }); + polylinesByVehicleRef.current[route.vehicleId] = roadPoly; + + if (totalMeters > 0 && onRouteDistanceUpdateRef.current) { + const distanceMi = Number((totalMeters / 1609.344).toFixed(1)); + onRouteDistanceUpdateRef.current(route.vehicleId, distanceMi); + } + } catch (err) { + console.warn( + `[Map] DirectionsService failed for vehicle ${route.vehicleId}, falling back to straight line:`, + err, + ); + drawFallback(route, strokeColor); + } + }; + + void (async () => { + // Request one route at a time to avoid Directions API rate-limit failures. + for (let routeIndex = 0; routeIndex < routesSnapshot.length; routeIndex += 1) { + if (cancelled) return; + await drawRoutePolyline(routesSnapshot[routeIndex]!, routeIndex); + } + })(); return () => { cancelled = true; @@ -246,7 +313,7 @@ function RoutePolylinesOverlay({ ); polylinesByVehicleRef.current = {}; }; - }, [map, routes, onRouteDistanceUpdate]); + }, [map, routesPathKey]); useEffect(() => { if (!map || typeof google === "undefined") return; @@ -268,13 +335,10 @@ function RoutePolylinesOverlay({ const poly = byVehicle[route.vehicleId]; if (!poly) continue; const committed = buildRoutePath(route, null); - if (committed.length < 2) continue; const key = routeCacheKey(committed); const cached = directionsCacheRef.current.get(key); if (cached && cached.path.length >= 2) { poly.setPath(cached.path); - } else { - poly.setPath(committed); } } }, [map, routes, pendingPinMove]); From 6d1615f66e0ea4f0c2bf73c0abe53dca2f4ff771 Mon Sep 17 00:00:00 2001 From: Kesar Sidhu Date: Fri, 19 Jun 2026 20:20:58 -0700 Subject: [PATCH 4/6] style(results): format Map.tsx for Prettier CI check Co-authored-by: Cursor --- app/ui/src/app/results/components/Map.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/ui/src/app/results/components/Map.tsx b/app/ui/src/app/results/components/Map.tsx index 62f8d8e4..80c9d64b 100644 --- a/app/ui/src/app/results/components/Map.tsx +++ b/app/ui/src/app/results/components/Map.tsx @@ -300,7 +300,11 @@ function RoutePolylinesOverlay({ void (async () => { // Request one route at a time to avoid Directions API rate-limit failures. - for (let routeIndex = 0; routeIndex < routesSnapshot.length; routeIndex += 1) { + for ( + let routeIndex = 0; + routeIndex < routesSnapshot.length; + routeIndex += 1 + ) { if (cancelled) return; await drawRoutePolyline(routesSnapshot[routeIndex]!, routeIndex); } From c0087c499ff78848453eb76382f83b6736104f65 Mon Sep 17 00:00:00 2001 From: Kesar Sidhu Date: Fri, 26 Jun 2026 09:41:23 -0700 Subject: [PATCH 5/6] fix(results): restore pin-commit polyline snap and avoid hydration mismatch Restore straight-line polyline fallback on directions cache miss after pin commit, recreate polylines when the directions effect clears refs first, dedupe step-path vertices in extractRoadPath, and defer missing-data errors to a client-only useEffect to prevent SSR hydration mismatch. Co-authored-by: Cursor --- app/ui/src/app/results/components/Map.tsx | 28 ++++++++++++++--- app/ui/src/app/results/page.tsx | 38 ++++++++++++++++------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/app/ui/src/app/results/components/Map.tsx b/app/ui/src/app/results/components/Map.tsx index 80c9d64b..141c00f1 100644 --- a/app/ui/src/app/results/components/Map.tsx +++ b/app/ui/src/app/results/components/Map.tsx @@ -135,9 +135,9 @@ function extractRoadPath( const path: google.maps.LatLng[] = []; for (const leg of route.legs ?? []) { - for (const step of leg.steps ?? []) { + for (const [i, step] of (leg.steps ?? []).entries()) { if (step.path?.length) { - path.push(...step.path); + path.push(...(i === 0 ? step.path : step.path.slice(1))); } } } @@ -336,13 +336,33 @@ function RoutePolylinesOverlay({ } for (const route of routes) { - const poly = byVehicle[route.vehicleId]; - if (!poly) continue; const committed = buildRoutePath(route, null); + if (committed.length < 2) continue; const key = routeCacheKey(committed); const cached = directionsCacheRef.current.get(key); + const routeIndex = routes.findIndex( + (r) => r.vehicleId === route.vehicleId, + ); + const strokeColor = routeColorHex(routeIndex); + + let poly = byVehicle[route.vehicleId]; + if (!poly) { + // Effect 1 may have cleared refs before this runs; show a path immediately. + const path = + cached && cached.path.length >= 2 ? cached.path : committed; + poly = new google.maps.Polyline({ + map, + path, + ...routePolylineOptions(strokeColor), + }); + byVehicle[route.vehicleId] = poly; + continue; + } + if (cached && cached.path.length >= 2) { poly.setPath(cached.path); + } else { + poly.setPath(committed); // straight-line until async fetch fills the cache } } }, [map, routes, pendingPinMove]); diff --git a/app/ui/src/app/results/page.tsx b/app/ui/src/app/results/page.tsx index 12e8e82e..46cd6e8c 100644 --- a/app/ui/src/app/results/page.tsx +++ b/app/ui/src/app/results/page.tsx @@ -70,11 +70,7 @@ function readInitialRoutes(): RouteLoadResult { if (!stored) { cachedRouteLoadKey = cacheKey; - cachedRouteLoadResult = { - routes: [], - error: - "No optimized routes found. Please run optimize from the edit page.", - }; + cachedRouteLoadResult = EMPTY_ROUTE_LOAD_RESULT; return cachedRouteLoadResult; } @@ -85,11 +81,7 @@ function readInitialRoutes(): RouteLoadResult { return cachedRouteLoadResult; } catch { cachedRouteLoadKey = cacheKey; - cachedRouteLoadResult = { - routes: [], - error: - "Could not read saved route data. Please run optimize again from the edit page.", - }; + cachedRouteLoadResult = EMPTY_ROUTE_LOAD_RESULT; return cachedRouteLoadResult; } } @@ -110,13 +102,37 @@ export default function ResultsPage() { () => EMPTY_ROUTE_LOAD_RESULT, ); const [draftRoutes, setDraftRoutes] = useState(null); + const [clientError, setClientError] = useState(null); const routes = draftRoutes ?? routeLoadResult.routes; - const error = routeLoadResult.error; + const error = clientError; const routesRef = useRef(routes); useEffect(() => { routesRef.current = routes; }, [routes]); + useEffect(() => { + const forceMock = MOCK_DATA_ENABLED + ? new URLSearchParams(window.location.search).get("mock") + : null; + if (forceMock === "1") return; + + const stored = sessionStorage.getItem("optimizeResults"); + if (!stored) { + setClientError( + "No optimized routes found. Please run optimize from the edit page.", + ); + return; + } + + try { + JSON.parse(stored); + } catch { + setClientError( + "Could not read saved route data. Please run optimize again from the edit page.", + ); + } + }, []); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isSheetExpanded, setIsSheetExpanded] = useState(false); const [isEditMode, setIsEditMode] = useState(false); From 73fbef6712d54af9cf9bf1f7d747326f290e0c3a Mon Sep 17 00:00:00 2001 From: Kesar Sidhu Date: Fri, 26 Jun 2026 09:45:13 -0700 Subject: [PATCH 6/6] fix(results): avoid setState in effect for client validation errors Replace the client-only error useEffect with useSyncExternalStore so validation runs after hydration without triggering react-hooks/set-state-in-effect. Co-authored-by: Cursor --- app/ui/src/app/results/page.tsx | 75 ++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/app/ui/src/app/results/page.tsx b/app/ui/src/app/results/page.tsx index 46cd6e8c..4b2dc7df 100644 --- a/app/ui/src/app/results/page.tsx +++ b/app/ui/src/app/results/page.tsx @@ -95,44 +95,69 @@ function subscribeToRouteStorage(onChange: () => void): () => void { }; } +let clientValidationPending = true; + +function readClientValidationError(): string | null { + if (typeof window === "undefined" || clientValidationPending) { + return null; + } + + const forceMock = MOCK_DATA_ENABLED + ? new URLSearchParams(window.location.search).get("mock") + : null; + if (forceMock === "1") return null; + + const stored = sessionStorage.getItem("optimizeResults"); + if (!stored) { + return "No optimized routes found. Please run optimize from the edit page."; + } + + try { + JSON.parse(stored); + return null; + } catch { + return "Could not read saved route data. Please run optimize again from the edit page."; + } +} + +function subscribeToClientValidation(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + + clientValidationPending = true; + const hydrationId = requestAnimationFrame(() => { + clientValidationPending = false; + onChange(); + }); + + window.addEventListener("storage", onChange); + window.addEventListener("optimize-results-updated", onChange); + + return () => { + cancelAnimationFrame(hydrationId); + window.removeEventListener("storage", onChange); + window.removeEventListener("optimize-results-updated", onChange); + }; +} + export default function ResultsPage() { const routeLoadResult = useSyncExternalStore( subscribeToRouteStorage, readInitialRoutes, () => EMPTY_ROUTE_LOAD_RESULT, ); + const clientValidationError = useSyncExternalStore( + subscribeToClientValidation, + readClientValidationError, + () => null, + ); const [draftRoutes, setDraftRoutes] = useState(null); - const [clientError, setClientError] = useState(null); const routes = draftRoutes ?? routeLoadResult.routes; - const error = clientError; + const error = clientValidationError; const routesRef = useRef(routes); useEffect(() => { routesRef.current = routes; }, [routes]); - useEffect(() => { - const forceMock = MOCK_DATA_ENABLED - ? new URLSearchParams(window.location.search).get("mock") - : null; - if (forceMock === "1") return; - - const stored = sessionStorage.getItem("optimizeResults"); - if (!stored) { - setClientError( - "No optimized routes found. Please run optimize from the edit page.", - ); - return; - } - - try { - JSON.parse(stored); - } catch { - setClientError( - "Could not read saved route data. Please run optimize again from the edit page.", - ); - } - }, []); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isSheetExpanded, setIsSheetExpanded] = useState(false); const [isEditMode, setIsEditMode] = useState(false);