+
{mapId && (
)}
{!mapId &&
- routes.map((route) => {
+ routes.map((route, routeIndex) => {
+ const accentColor = routeColorHex(routeIndex);
+ const iconUrl = markerSvgDataUrl(accentColor);
+ const scaledSize = getMarkerScaledSize();
const sorted = [...route.stops].sort(
(a, b) => a.sequence - b.sequence,
);
@@ -485,6 +630,18 @@ export default function MapComponent({
key={stop.id}
position={position}
title={stop.address}
+ icon={
+ scaledSize
+ ? {
+ url: iconUrl,
+ scaledSize,
+ anchor: new google.maps.Point(
+ MARKER_ICON_WIDTH / 2,
+ MARKER_ICON_HEIGHT,
+ ),
+ }
+ : { url: iconUrl }
+ }
draggable={isEditMode}
onDragEnd={(e) => {
const latLng = e.latLng;
diff --git a/app/ui/src/app/results/components/MapStopHoverOverlay.tsx b/app/ui/src/app/results/components/MapStopHoverOverlay.tsx
new file mode 100644
index 00000000..209dc6d6
--- /dev/null
+++ b/app/ui/src/app/results/components/MapStopHoverOverlay.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { useGoogleMap } from "@react-google-maps/api";
+import type { HoveredStopInfo } from "../types";
+import StopHoverCard from "./StopHoverCard";
+
+type MapStopHoverOverlayProps = {
+ hovered: HoveredStopInfo | null;
+};
+
+function useContainerPixelPosition(
+ map: google.maps.Map | null,
+ lat: number,
+ lng: number,
+): { x: number; y: number } | null {
+ const [pixel, setPixel] = useState<{ x: number; y: number } | null>(null);
+
+ useEffect(() => {
+ if (!map) return;
+
+ const overlay = new google.maps.OverlayView();
+ overlay.onAdd = () => {};
+ overlay.draw = () => {};
+ overlay.setMap(map);
+
+ const update = () => {
+ const projection = overlay.getProjection();
+ if (!projection) return;
+ const point = projection.fromLatLngToContainerPixel(
+ new google.maps.LatLng(lat, lng),
+ );
+ if (point) setPixel({ x: point.x, y: point.y });
+ };
+
+ const idleListener = map.addListener("idle", update);
+ const boundsListener = map.addListener("bounds_changed", update);
+ update();
+
+ return () => {
+ google.maps.event.removeListener(idleListener);
+ google.maps.event.removeListener(boundsListener);
+ overlay.setMap(null);
+ };
+ }, [map, lat, lng]);
+
+ return pixel;
+}
+
+function PositionedStopHoverCard({ hovered }: { hovered: HoveredStopInfo }) {
+ const map = useGoogleMap();
+ const pixel = useContainerPixelPosition(
+ map ?? null,
+ hovered.lat,
+ hovered.lng,
+ );
+
+ if (!map || !pixel) return null;
+
+ const mapDiv = map.getDiv();
+ if (!mapDiv) return null;
+
+ return createPortal(
+
+
+
,
+ mapDiv,
+ );
+}
+
+export default function MapStopHoverOverlay({
+ hovered,
+}: MapStopHoverOverlayProps) {
+ if (!hovered) return null;
+ return
;
+}
diff --git a/app/ui/src/app/results/components/MobileResultsNavbar.tsx b/app/ui/src/app/results/components/MobileResultsNavbar.tsx
new file mode 100644
index 00000000..095f68c5
--- /dev/null
+++ b/app/ui/src/app/results/components/MobileResultsNavbar.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import {
+ MOBILE_NAVBAR_LEFT_GROUP,
+ MOBILE_NAVBAR_MENU_BTN,
+ MOBILE_NAVBAR_ROOT,
+} from "../../edit/formStyles.v2";
+import {
+ RESULTS_MOBILE_NAV_CANCEL_BTN,
+ RESULTS_MOBILE_NAV_SAVE_BTN,
+ RESULTS_MOBILE_NAV_TITLE,
+} from "../formStyles.mobile";
+
+type MobileResultsNavbarProps = {
+ onMenuClick: () => void;
+ onSave?: () => void;
+ onCancel?: () => void;
+ showCancel?: boolean;
+ saveDisabled?: boolean;
+};
+
+export default function MobileResultsNavbar({
+ onMenuClick,
+ onSave,
+ onCancel,
+ showCancel = false,
+ saveDisabled = false,
+}: MobileResultsNavbarProps) {
+ return (
+
+ );
+}
diff --git a/app/ui/src/app/results/components/ResultsBottomSheet.tsx b/app/ui/src/app/results/components/ResultsBottomSheet.tsx
new file mode 100644
index 00000000..34a048d2
--- /dev/null
+++ b/app/ui/src/app/results/components/ResultsBottomSheet.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+import MobileEditPageFooter from "../../edit/components/layout/footer/MobileEditPageFooter";
+import type { Route } from "../types";
+import {
+ RESULTS_BOTTOM_SHEET_BODY,
+ RESULTS_BOTTOM_SHEET_BTN_PILL,
+ RESULTS_BOTTOM_SHEET_BTN_RECT_FILLED,
+ RESULTS_BOTTOM_SHEET_BTN_RECT_OUTLINE,
+ RESULTS_BOTTOM_SHEET_COLLAPSED,
+ RESULTS_BOTTOM_SHEET_EXPANDED,
+ RESULTS_BOTTOM_SHEET_HANDLE,
+ RESULTS_BOTTOM_SHEET_HEADER,
+ RESULTS_BOTTOM_SHEET_HEADER_ROW,
+ RESULTS_BOTTOM_SHEET_HEADER_TEXT,
+ RESULTS_BOTTOM_SHEET_ROOT,
+ RESULTS_BOTTOM_SHEET_SUBTITLE,
+ RESULTS_BOTTOM_SHEET_TITLE,
+ RESULTS_SHEET_FOOTER_WRAP,
+} from "../formStyles.mobile";
+import Sidebar from "./Sidebar";
+
+type ResultsBottomSheetProps = {
+ routes: Route[];
+ isExpanded: boolean;
+ onExpandedChange: (expanded: boolean) => void;
+ isEditMode: boolean;
+ onEditModeChange: (value: boolean) => void;
+ onExportClick: () => void;
+ onUpdateStopNote: (routeId: string, stopId: string, note: string) => void;
+ onExportRoute: (vehicleId: string) => void;
+ onDuplicateRoute: (vehicleId: string) => void;
+ onDeleteRoute: (vehicleId: string) => void;
+};
+
+export default function ResultsBottomSheet({
+ routes,
+ isExpanded,
+ onExpandedChange,
+ isEditMode,
+ onEditModeChange,
+ onExportClick,
+ onUpdateStopNote,
+ onExportRoute,
+ onDuplicateRoute,
+ onDeleteRoute,
+}: ResultsBottomSheetProps) {
+ const totalStops = useMemo(
+ () => routes.reduce((sum, r) => sum + r.stops.length, 0),
+ [routes],
+ );
+
+ const handleEditToggle = useCallback(() => {
+ if (isEditMode) {
+ onEditModeChange(false);
+ } else {
+ onEditModeChange(true);
+ onExpandedChange(true);
+ }
+ }, [isEditMode, onEditModeChange, onExpandedChange]);
+
+ return (
+
+
+
+
+
+
+
Optimized Routes
+
+ {routes.length} route{routes.length === 1 ? "" : "s"} with{" "}
+ {totalStops} total stop
+ {totalStops === 1 ? "" : "s"}
+
+
+
+ {isExpanded ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+}
diff --git a/app/ui/src/app/results/components/ResultsNavRail.tsx b/app/ui/src/app/results/components/ResultsNavRail.tsx
new file mode 100644
index 00000000..829eeba3
--- /dev/null
+++ b/app/ui/src/app/results/components/ResultsNavRail.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import Link from "next/link";
+import {
+ SIDEBAR_NAV,
+ SIDEBAR_NAV_ITEM_ACTIVE,
+ SIDEBAR_NAV_ITEM_INACTIVE,
+ SIDEBAR_NAV_LABEL_ACTIVE,
+ SIDEBAR_NAV_LABEL_INACTIVE,
+ SIDEBAR_NAV_PILL_ACTIVE,
+ SIDEBAR_NAV_PILL_INACTIVE,
+ SIDEBAR_ROOT,
+} from "../../edit/formStyles.v2";
+
+const MANAGE_ICON = (
+
+);
+
+const RESULTS_ICON = (
+
+);
+
+export default function ResultsNavRail() {
+ return (
+
+ );
+}
diff --git a/app/ui/src/app/results/components/RouteCard.tsx b/app/ui/src/app/results/components/RouteCard.tsx
new file mode 100644
index 00000000..92961fa4
--- /dev/null
+++ b/app/ui/src/app/results/components/RouteCard.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import type { Route } from "../types";
+import { routeColorHex } from "../utils/routeColors";
+import EditableStopItem from "./EditableStopItem";
+import RouteCardMenu from "./RouteCardMenu";
+
+type RouteCardProps = {
+ route: Route;
+ routeIndex: number;
+ isExpanded: boolean;
+ isEditMode: boolean;
+ isMenuOpen: boolean;
+ menuOpensUp?: boolean;
+ onToggleExpanded: () => void;
+ onMenuOpenChange: (open: boolean) => void;
+ onExportRoute: () => void;
+ onDuplicateRoute: () => void;
+ onDeleteRoute: () => void;
+ onUpdateStopNote: (stopId: string, note: string) => void;
+};
+
+function formatEstTime(minutes: number | undefined): string {
+ if (minutes == null) return "—";
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ if (h === 0) return `${m}m`;
+ return m === 0 ? `${h}h` : `${h}h${m}m`;
+}
+
+export default function RouteCard({
+ route,
+ routeIndex,
+ isExpanded,
+ isEditMode,
+ isMenuOpen,
+ menuOpensUp = false,
+ onToggleExpanded,
+ onMenuOpenChange,
+ onExportRoute,
+ onDuplicateRoute,
+ onDeleteRoute,
+ onUpdateStopNote,
+}: RouteCardProps) {
+ const sortedStops = [...route.stops].sort((a, b) => a.sequence - b.sequence);
+ const accent = routeColorHex(routeIndex);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+}
diff --git a/app/ui/src/app/results/components/RouteCardMenu.tsx b/app/ui/src/app/results/components/RouteCardMenu.tsx
new file mode 100644
index 00000000..fed3d948
--- /dev/null
+++ b/app/ui/src/app/results/components/RouteCardMenu.tsx
@@ -0,0 +1,282 @@
+"use client";
+
+import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+type RouteCardMenuProps = {
+ routeLabel: string;
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onExport: () => void;
+ onDuplicate: () => void;
+ onDelete: () => void;
+ /** Open upward inside bottom sheet so Delete stays visible */
+ placement?: "up" | "down";
+};
+
+function DownloadIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function DuplicateIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function TrashIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+export default function RouteCardMenu({
+ routeLabel,
+ isOpen,
+ onOpenChange,
+ onExport,
+ onDuplicate,
+ onDelete,
+ placement = "down",
+}: RouteCardMenuProps) {
+ const menuId = useId();
+ const rootRef = useRef
(null);
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const [menuPosition, setMenuPosition] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+
+ useLayoutEffect(() => {
+ if (!isOpen) return;
+
+ function updatePosition() {
+ const trigger = triggerRef.current;
+ const menu = menuRef.current;
+ if (!trigger || !menu) return;
+ const rect = trigger.getBoundingClientRect();
+ const menuWidth = menu.offsetWidth;
+ const menuHeight = menu.offsetHeight;
+ const gap = 4;
+ const left = Math.min(
+ Math.max(8, rect.right - menuWidth),
+ window.innerWidth - menuWidth - 8,
+ );
+ const top =
+ placement === "up" ? rect.top - menuHeight - gap : rect.bottom + gap;
+ setMenuPosition({ top, left });
+ }
+
+ updatePosition();
+ window.addEventListener("resize", updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+ return () => {
+ window.removeEventListener("resize", updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ };
+ }, [isOpen, placement]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ itemRefs.current[0]?.focus();
+ }, [isOpen]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const handlePointerDown = (e: MouseEvent) => {
+ const target = e.target as Node;
+ if (rootRef.current?.contains(target)) return;
+ const menu = document.getElementById(menuId);
+ if (menu?.contains(target)) return;
+ onOpenChange(false);
+ };
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onOpenChange(false);
+ return;
+ }
+ const menu = document.getElementById(menuId);
+ if (!menu?.contains(e.target as Node)) return;
+
+ const items = itemRefs.current.filter(Boolean) as HTMLButtonElement[];
+ if (items.length === 0) return;
+
+ const currentIndex = items.findIndex(
+ (el) => el === document.activeElement,
+ );
+ let nextIndex = currentIndex;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ nextIndex =
+ currentIndex < 0 || currentIndex >= items.length - 1
+ ? 0
+ : currentIndex + 1;
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ nextIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
+ } else if (e.key === "Home") {
+ e.preventDefault();
+ nextIndex = 0;
+ } else if (e.key === "End") {
+ e.preventDefault();
+ nextIndex = items.length - 1;
+ } else {
+ return;
+ }
+
+ items[nextIndex]?.focus();
+ };
+ document.addEventListener("mousedown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [isOpen, menuId, onOpenChange]);
+
+ const setItemRef = (index: number) => (el: HTMLButtonElement | null) => {
+ itemRefs.current[index] = el;
+ };
+
+ const menuPanel = isOpen ? (
+
+ ) : null;
+
+ return (
+
+
+
+ {typeof document !== "undefined" && menuPanel
+ ? createPortal(menuPanel, document.body)
+ : null}
+
+ );
+}
diff --git a/app/ui/src/app/results/components/Sidebar.tsx b/app/ui/src/app/results/components/Sidebar.tsx
index 76a6bed9..4cb3b245 100644
--- a/app/ui/src/app/results/components/Sidebar.tsx
+++ b/app/ui/src/app/results/components/Sidebar.tsx
@@ -2,14 +2,18 @@
import { useMemo, useState } from "react";
import type { Route } from "../types";
-import { routeColorHex } from "../utils/routeColors";
-import EditableStopItem from "./EditableStopItem";
+import RouteCard from "./RouteCard";
type SidebarProps = {
routes: Route[];
isEditMode: boolean;
onEditModeChange: (value: boolean) => void;
onUpdateStopNote: (routeId: string, stopId: string, note: string) => void;
+ onExportRoute: (vehicleId: string) => void;
+ onDuplicateRoute: (vehicleId: string) => void;
+ onDeleteRoute: (vehicleId: string) => void;
+ /** Desktop sidebar vs mobile bottom-sheet list body */
+ variant?: "sidebar" | "sheet";
};
export default function Sidebar({
@@ -17,10 +21,16 @@ export default function Sidebar({
isEditMode,
onEditModeChange,
onUpdateStopNote,
+ onExportRoute,
+ onDuplicateRoute,
+ onDeleteRoute,
+ variant = "sidebar",
}: SidebarProps) {
+ const isSheet = variant === "sheet";
const [expandedRouteIds, setExpandedRouteIds] = useState>(
() => new Set(),
);
+ const [openMenuRouteId, setOpenMenuRouteId] = useState(null);
const totalStops = useMemo(
() => routes.reduce((sum, r) => sum + r.stops.length, 0),
@@ -36,161 +46,75 @@ export default function Sidebar({
});
}
- function formatEstTime(minutes: number | undefined): string {
- if (minutes == null) return "—";
- const h = Math.floor(minutes / 60);
- const m = minutes % 60;
- if (h === 0) return `${m}m`;
- return m === 0 ? `${h}h` : `${h}h${m}m`;
- }
-
return (