import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; import { DndContext, KeyboardSensor, PointerSensor, TouchSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useBranding } from "../BrandingContext.js"; import type { RouteMapStop } from "../components/RouteMap.js"; // Leaflet is heavy and only needed on this page — load it as a separate chunk. const RouteMap = lazy(() => import("../components/RouteMap.js")); // ─── Types (mirror groombook/api /api/routes responses) ───────────────────────── type RouteStatus = "draft" | "optimized" | "in_progress" | "completed"; interface RouteRow { id: string; staffId: string; routeDate: string; status: RouteStatus; totalTravelMins: number | null; totalDistanceKm: string | null; } interface ConflictFlags { hasConflict: boolean; } interface RouteStop { id: string; appointmentId: string; stopOrder: number; latitude: number; longitude: number; travelMinsFromPrev: number | null; travelDistanceKmFromPrev: string | null; bufferMins: number; appointmentStartTime: string; appointmentEndTime: string; appointmentStatus: string; clientId: string; clientName: string; clientAddress: string | null; conflict: ConflictFlags; } interface RouteResponse { route: RouteRow; stops: RouteStop[]; hasConflicts: boolean; conflictCount: number; warnings?: string[]; skipped?: Array<{ appointmentId: string; clientName: string; reason: string }>; } interface StaffMember { id: string; name: string; role: "groomer" | "receptionist" | "manager"; active: boolean; } // ─── Helpers ──────────────────────────────────────────────────────────────────── function todayIso(): string { return new Date().toISOString().slice(0, 10); } function fmtDuration(mins: number | null | undefined): string { if (mins == null) return "—"; if (mins < 60) return `${mins} min`; const h = Math.floor(mins / 60); const m = mins % 60; return m === 0 ? `${h} h` : `${h} h ${m} min`; } function fmtTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } // ─── Navigation export ──────────────────────────────────────────────────────── /** Navigation target platforms supported by the API export endpoints. */ type NavigationPlatform = "google-maps" | "apple-maps"; type DevicePlatform = "ios" | "android" | "other"; /** * Best-effort mobile-OS detection so we can surface the most useful navigation * app first. Apple Maps deep links (`maps://`) only resolve on iOS; everywhere * else Google Maps is the safe default. iPadOS 13+ reports a desktop UA, so we * also treat a touch-capable "MacIntel" device as iOS. */ function detectPlatform(): DevicePlatform { if (typeof navigator === "undefined") return "other"; const ua = navigator.userAgent || ""; if (/iphone|ipad|ipod/i.test(ua)) return "ios"; if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "ios"; if (/android/i.test(ua)) return "android"; return "other"; } // ─── Offline map-tile pre-warming ──────────────────────────────────────────── /** OSM tile zoom levels pre-fetched around a route so the map renders offline. */ const PREWARM_ZOOM_LEVELS = [12, 13, 14] as const; /** Hard cap on tiles fetched per pre-warm pass — keeps us friendly to OSM. */ const MAX_PREWARM_TILES = 80; /** Subdomains Leaflet's default OSM TileLayer rotates through (`{s}`). */ const TILE_SUBDOMAINS = ["a", "b", "c"] as const; /** Web-Mercator longitude → tile X index at the given zoom. */ function lonToTileX(lon: number, z: number): number { return Math.floor(((lon + 180) / 360) * 2 ** z); } /** Web-Mercator latitude → tile Y index at the given zoom. */ function latToTileY(lat: number, z: number): number { const rad = (lat * Math.PI) / 180; return Math.floor( ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z ); } /** * Warm the browser/service-worker cache with the OSM tiles covering the route's * bounding box (plus a one-tile margin) across a few zoom levels. Tiles are * fetched via `new Image()` so they hit the same URLs Leaflet later requests and * land in the CacheFirst tile cache, making the map viewable offline. Bounded by * MAX_PREWARM_TILES so a sprawling route never floods the network. */ function prewarmRouteTiles( stops: Array<{ latitude: number; longitude: number }> ): void { if (typeof window === "undefined" || stops.length === 0) return; const lats = stops.map((s) => s.latitude); const lons = stops.map((s) => s.longitude); const minLat = Math.min(...lats); const maxLat = Math.max(...lats); const minLon = Math.min(...lons); const maxLon = Math.max(...lons); const urls: string[] = []; for (const z of PREWARM_ZOOM_LEVELS) { const x0 = lonToTileX(minLon, z) - 1; const x1 = lonToTileX(maxLon, z) + 1; // Tile Y grows as latitude decreases, so maxLat → smaller Y. const y0 = latToTileY(maxLat, z) - 1; const y1 = latToTileY(minLat, z) + 1; for (let x = x0; x <= x1; x++) { for (let y = y0; y <= y1; y++) { if (x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) continue; const s = TILE_SUBDOMAINS[(x + y) % TILE_SUBDOMAINS.length]; urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`); } } } for (const url of urls.slice(0, MAX_PREWARM_TILES)) { const img = new Image(); img.src = url; } } // ─── Responsive layout ──────────────────────────────────────────────────────── /** Tracks a `max-width` media query so the page can adapt to phone widths. */ function useIsMobile(maxWidthPx = 768): boolean { const query = `(max-width: ${maxWidthPx}px)`; const [isMobile, setIsMobile] = useState( () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false ); useEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; const mq = window.matchMedia(query); const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); setIsMobile(mq.matches); mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }, [query]); return isMobile; } const STATUS_STYLES: Record = { draft: { bg: "#f1f5f9", fg: "#475569", label: "Draft" }, optimized: { bg: "#ecfdf5", fg: "#047857", label: "Optimized" }, in_progress: { bg: "#eff6ff", fg: "#1d4ed8", label: "In progress" }, completed: { bg: "#f5f3ff", fg: "#6d28d9", label: "Completed" }, }; function StatusBadge({ status }: { status: RouteStatus }) { const s = STATUS_STYLES[status] ?? STATUS_STYLES.draft; return ( {s.label} ); } const inputStyle: React.CSSProperties = { padding: "0.4rem 0.6rem", borderRadius: 6, border: "1px solid #cbd5e1", fontSize: 14, }; /** * A single draggable stop card. The drag handle (⠿) carries the dnd-kit * listeners so the rest of the card stays scrollable/selectable; the handle is * sized for touch and works with pointer, touch and keyboard sensors. */ function SortableStop({ stop, primaryColor, disabled, }: { stop: RouteStop; primaryColor: string; disabled: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: stop.id, disabled }); return (
{stop.stopOrder}
{stop.clientName} {fmtTime(stop.appointmentStartTime)}
{stop.clientAddress || "No address on file"}
{stop.stopOrder === 1 || stop.travelMinsFromPrev == null ? "Start of route" : `${fmtDuration(stop.travelMinsFromPrev)} travel from previous`}
{stop.conflict?.hasConflict && (
⚠ Tight schedule — travel + buffer may exceed the gap
)}
); } /** * Navigation export controls. Fetches a platform deep-link from the API and opens * it. The button matching the detected device OS is shown prominently (filled); * the other is offered as a secondary outline button. On desktop both are * secondary and Google Maps leads. */ function NavExportButtons({ routeId, primaryColor, fullWidth, }: { routeId: string; primaryColor: string; fullWidth: boolean; }) { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const platform = useMemo(detectPlatform, []); const openIn = useCallback( async (target: NavigationPlatform) => { setBusy(target); setError(null); // Pre-open a tab synchronously: mobile Safari/Chrome block window.open() // calls that happen after an await (no longer in the user-gesture turn). const win = window.open("", "_blank"); try { const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/export/${target}`); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Export failed (${r.status})`); } const { url } = (await r.json()) as { url: string }; if (win) win.location.href = url; else window.location.href = url; } catch (e) { win?.close(); setError(e instanceof Error ? e.message : "Export failed"); } finally { setBusy(null); } }, [routeId] ); const baseBtn: React.CSSProperties = { padding: "0.55rem 1rem", borderRadius: 6, fontWeight: 600, fontSize: 14, cursor: busy ? "wait" : "pointer", flex: fullWidth ? "1 1 0" : "0 0 auto", }; const primaryBtn: React.CSSProperties = { ...baseBtn, border: "none", background: primaryColor, color: "#fff", }; const secondaryBtn: React.CSSProperties = { ...baseBtn, border: `1px solid ${primaryColor}`, background: "#fff", color: primaryColor, }; const label = (p: NavigationPlatform) => busy === p ? "Opening…" : p === "google-maps" ? "Open in Google Maps" : "Open in Apple Maps"; const google = ( ); const apple = ( ); // Prominent (filled) button first; secondary second. const ordered = platform === "ios" ? [apple, google] : [google, apple]; return (
Navigate {ordered}
{error &&
{error}
}
); } // ─── Page ─────────────────────────────────────────────────────────────────────── export function RoutesPage() { const { branding } = useBranding(); const primaryColor = branding.primaryColor || "#4f8a6f"; const [me, setMe] = useState(null); const [meLoaded, setMeLoaded] = useState(false); const [groomers, setGroomers] = useState([]); const [staffId, setStaffId] = useState(""); const [date, setDate] = useState(todayIso()); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [optimizing, setOptimizing] = useState(false); const [reordering, setReordering] = useState(false); const [manuallyReordered, setManuallyReordered] = useState(false); const [error, setError] = useState(null); const isGroomer = me?.role === "groomer"; const isMobile = useIsMobile(); // Resolve the current staff member; groomers are pinned to their own route. useEffect(() => { fetch("/api/staff/me") .then((r) => (r.ok ? r.json() : null)) .then((row: StaffMember | null) => { setMe(row); if (row?.role === "groomer") setStaffId(row.id); }) .catch(() => setMe(null)) .finally(() => setMeLoaded(true)); }, []); // Managers / receptionists pick a groomer; groomers never see the selector. useEffect(() => { if (!meLoaded || isGroomer) return; fetch("/api/staff") .then((r) => (r.ok ? r.json() : [])) .then((rows: StaffMember[]) => { const gs = rows.filter((s) => s.active && s.role === "groomer"); setGroomers(gs); setStaffId((cur) => cur || gs[0]?.id || ""); }) .catch(() => setGroomers([])); }, [meLoaded, isGroomer]); const loadRoute = useCallback(async () => { if (!staffId || !date) return; setLoading(true); setError(null); try { const r = await fetch( `/api/routes/daily?staffId=${encodeURIComponent(staffId)}&date=${encodeURIComponent(date)}` ); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Failed to load route (${r.status})`); } setData(await r.json()); setManuallyReordered(false); } catch (e) { setData(null); setError(e instanceof Error ? e.message : "Failed to load route"); } finally { setLoading(false); } }, [staffId, date]); useEffect(() => { void loadRoute(); }, [loadRoute]); const optimize = useCallback(async () => { if (!staffId || !date) return; setOptimizing(true); setError(null); try { const r = await fetch("/api/routes/optimize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staffId, date }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Optimization failed (${r.status})`); } setData(await r.json()); setManuallyReordered(false); } catch (e) { setError(e instanceof Error ? e.message : "Optimization failed"); } finally { setOptimizing(false); } }, [staffId, date]); // Drag-to-reorder: pointer for desktop, touch (press-and-hold) for mobile // groomers, keyboard for accessibility. Touch uses a short delay so vertical // scrolling of the stop list still works without triggering a drag. const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); // Persist a manually reordered stop list. Optimistic: the UI is updated // immediately from the dropped order and rolled back if the PATCH fails. const reorder = useCallback( async (orderedIds: string[]) => { const routeId = data?.route?.id; if (!routeId) return; const previous = data; // Optimistic local update: renumber stopOrder to match the new order so // the list and the map reflect the drop before the server responds. const byId = new Map((data?.stops ?? []).map((s) => [s.id, s])); const optimisticStops = orderedIds .map((id, i) => { const s = byId.get(id); return s ? { ...s, stopOrder: i + 1 } : null; }) .filter((s): s is RouteStop => s !== null); setData((cur) => (cur ? { ...cur, stops: optimisticStops } : cur)); setManuallyReordered(true); setReordering(true); setError(null); try { const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/reorder`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stopOrder: orderedIds }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Reorder failed (${r.status})`); } // Server recomputes travel legs, buffers and conflict flags — adopt its // authoritative response over the optimistic guess. setData(await r.json()); } catch (e) { setData(previous); // rollback setError(e instanceof Error ? e.message : "Reorder failed"); } finally { setReordering(false); } }, [data] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const ids = (data?.stops ?? []).map((s) => s.id); const from = ids.indexOf(String(active.id)); const to = ids.indexOf(String(over.id)); if (from === -1 || to === -1) return; void reorder(arrayMove(ids, from, to)); }, [data, reorder] ); const mapStops: RouteMapStop[] = useMemo( () => (data?.stops ?? []).map((s) => ({ id: s.id, stopOrder: s.stopOrder, latitude: s.latitude, longitude: s.longitude, clientName: s.clientName, })), [data] ); // Pre-warm OSM map tiles for the route area whenever a route (re)loads or is // re-optimized, so the map stays viewable offline. Runs after today's route is // fetched on page load and after every optimize/reorder that yields new stops. useEffect(() => { if (mapStops.length > 0) prewarmRouteTiles(mapStops); }, [mapStops]); const stops = data?.stops ?? []; const route = data?.route ?? null; return (

Route Planner

{route && }
{/* Controls */}
{!isGroomer && ( )}
{error && (
{error}
)} {data?.warnings && data.warnings.length > 0 && (
{data.warnings.map((w, i) => (
{w}
))}
)} {/* Summary */}
{/* Navigation export — open the route in the device's maps app */} {route && stops.length > 0 && (
)}
{/* Map */}
{mapStops.length > 0 ? ( Loading map…}> ) : ( {loading ? "Loading route…" : "No stops to display. Click Optimize to build the route."} )}
{/* Stop list panel — drag-to-reorder */}
{stops.length === 0 && !loading && (
No stops for this day.
)} {stops.length > 0 && ( <> {manuallyReordered && (
Stops reordered manually. Re-optimize to recompute the best route.
)} s.id)} strategy={verticalListSortingStrategy}> {stops.map((s) => ( ))} )}
); } function Summary({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function Centered({ children }: { children: React.ReactNode }) { return (
{children}
); }