From 59a29a2d03336440d87aa8aa8f7ea672c0372ce7 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 02:57:49 +0000 Subject: [PATCH] feat(GRO-2159): drag-to-reorder + re-optimize on route planner (#63) --- UAT_PLAYBOOK.md | 14 ++ package.json | 3 + pnpm-lock.yaml | 61 ++++++++ src/__tests__/Routes.test.tsx | 30 ++++ src/pages/Routes.tsx | 270 ++++++++++++++++++++++++++++------ 5 files changed, 330 insertions(+), 48 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 8992ad7..f2f0e3b 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -491,6 +491,20 @@ The admin Route Planner lives at `/admin/routes`. It shows a groomer's geocoded | TC-WEB-5.28.7 | Groomer role auto-filter | Sign in as a groomer and open `/admin/routes`. | No groomer selector is shown. The page loads the signed-in groomer's own route for the selected date. The groomer cannot view another groomer's route. | | TC-WEB-5.28.8 | Empty / no-route state | Select a date with no appointments. | The map area and stop panel show a friendly empty state ("No stops…"). No crash; **Optimize** is still clickable. | +### 5.29 Route Planner — Drag-to-Reorder & Re-optimize (GRO-2159) + +The stop-list panel is drag-sortable (`@dnd-kit`). Each stop card has a grab handle (⠿). Dropping a stop in a new position calls `PATCH /api/routes/:routeId/reorder` with `{ stopOrder: [routeStopId…] }` (full first-to-last order); the UI updates optimistically and rolls back on error. The server recomputes per-leg travel, buffers, totals and tight-schedule conflict flags, and the panel/map/summary adopt the response. A "tight schedule" warning is shown on any stop whose gap is shorter than its travel + buffer. After a manual reorder a hint with a **Re-optimize** button appears (re-runs `POST /api/routes/optimize`). Drag works via mouse (desktop), press-and-hold touch (mobile groomers), and keyboard (focus handle → Space → arrows → Space). + +| Test Case | Description | Steps | Expected Result | +|-----------|-------------|-------|-----------------| +| TC-WEB-5.29.1 | Drag handle present | Open `/admin/routes` for a route with ≥2 stops. | Each stop card shows a grab handle (⠿) with an accessible label "Drag to reorder ". | +| TC-WEB-5.29.2 | Reorder persists | Drag a stop to a new position and drop it. | A `PATCH /api/routes/:routeId/reorder` fires with the new `stopOrder` (every stop id once, new order). Stop numbers, the map polyline order, and travel-from-previous labels refresh to match. | +| TC-WEB-5.29.3 | Optimistic update + rollback | Simulate a failing reorder (e.g. server returns an error / offline). | The list shows the new order immediately, then reverts to the prior order when the PATCH fails, and an error message is shown. No stuck/partial order. | +| TC-WEB-5.29.4 | Tight-schedule warning re-evaluated | Reorder so two stops are too close together. | The affected stop card shows "⚠ Tight schedule — travel + buffer may exceed the gap" (red border) after the server recomputes; warnings clear on a roomier order. | +| TC-WEB-5.29.5 | Re-optimize button | After a manual drag reorder, locate the hint banner. | A "Stops reordered manually…" hint with a **Re-optimize** button appears. Clicking it fires `POST /api/routes/optimize` and the hint clears once the optimized route loads. The hint is absent before any manual reorder. | +| TC-WEB-5.29.6 | Touch / mobile drag | On a touch device (or mobile emulation), press-and-hold a stop's handle (~200ms) then drag. | The stop lifts and can be dropped in a new position; page scroll is not hijacked by a quick swipe. Reorder persists as in 5.29.2. | +| TC-WEB-5.29.7 | Groomer reorders own route | Sign in as a groomer, reorder stops on the own route. | Reorder succeeds (groomer is authorized for their own route). | + ## 6. Pass/Fail Criteria **Pass:** diff --git a/package.json b/package.json index ec73670..4be9039 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "test:e2e": "playwright test -c e2e/playwright.config.ts" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@groombook/types": "workspace:*", "@stripe/react-stripe-js": "^6.1.0", "@stripe/stripe-js": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03075fc..1736644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.6) '@groombook/types': specifier: workspace:* version: link:packages/types @@ -748,6 +757,28 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -3176,6 +3207,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4261,6 +4295,31 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -6627,6 +6686,8 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/__tests__/Routes.test.tsx b/src/__tests__/Routes.test.tsx index 935a697..a20c49d 100644 --- a/src/__tests__/Routes.test.tsx +++ b/src/__tests__/Routes.test.tsx @@ -79,6 +79,9 @@ function mockFetch(meRole: "manager" | "groomer") { if (url === "/api/routes/optimize" && opts?.method === "POST") { return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response); } + if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") { + return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response); + } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response); }); } @@ -124,6 +127,33 @@ describe("RoutesPage", () => { expect(screen.queryByText("Groomer")).not.toBeInTheDocument(); }); + it("renders a drag handle for each stop (drag-to-reorder enabled)", async () => { + global.fetch = mockFetch("manager") as unknown as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); + expect(screen.getByLabelText("Drag to reorder Alice")).toBeInTheDocument(); + expect(screen.getByLabelText("Drag to reorder Bob")).toBeInTheDocument(); + }); + + it("flags the tight-schedule conflict on the affected stop", async () => { + global.fetch = mockFetch("manager") as unknown as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Bob")).toBeInTheDocument()); + expect( + screen.getByText(/Tight schedule — travel \+ buffer may exceed the gap/) + ).toBeInTheDocument(); + }); + + it("does not show the re-optimize hint before any manual reorder", async () => { + global.fetch = mockFetch("manager") as unknown as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); + expect(screen.queryByText("Re-optimize")).not.toBeInTheDocument(); + }); + it("calls the optimize endpoint when Optimize is clicked", async () => { const fetchMock = mockFetch("manager"); global.fetch = fetchMock as unknown as typeof fetch; diff --git a/src/pages/Routes.tsx b/src/pages/Routes.tsx index 5d62a30..d0185e0 100644 --- a/src/pages/Routes.tsx +++ b/src/pages/Routes.tsx @@ -1,4 +1,22 @@ 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"; @@ -109,6 +127,100 @@ const inputStyle: React.CSSProperties = { 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 +
+ )} +
+
+ ); +} + // ─── Page ─────────────────────────────────────────────────────────────────────── export function RoutesPage() { @@ -124,6 +236,8 @@ export function RoutesPage() { 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"; @@ -166,6 +280,7 @@ export function RoutesPage() { 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"); @@ -193,6 +308,7 @@ export function RoutesPage() { 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 { @@ -200,6 +316,71 @@ export function RoutesPage() { } }, [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) => ({ @@ -299,59 +480,52 @@ export function RoutesPage() { )} - {/* Stop list panel */} + {/* Stop list panel — drag-to-reorder */}
{stops.length === 0 && !loading && (
No stops for this day.
)} - {stops.map((s) => ( -
-
0 && ( + <> + {manuallyReordered && ( +
+ + Stops reordered manually. Re-optimize to recompute the best route. + + +
+ )} + - {s.stopOrder} -
-
-
- {s.clientName} - {fmtTime(s.appointmentStartTime)} -
-
{s.clientAddress || "No address on file"}
-
- {s.stopOrder === 1 || s.travelMinsFromPrev == null - ? "Start of route" - : `${fmtDuration(s.travelMinsFromPrev)} travel from previous`} -
- {s.conflict?.hasConflict && ( -
- ⚠ Tight schedule — travel + buffer may exceed the gap -
- )} -
-
- ))} + s.id)} strategy={verticalListSortingStrategy}> + {stops.map((s) => ( + + ))} + + + + )}