From 3e22cc4243c66076616ae3dc8029dc10671143f9 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 18:05:50 +0000 Subject: [PATCH] feat(GRO-2156): travel buffer + reorder endpoint (Phase 2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply travel buffer between consecutive stops (default 15 / businessSettings.defaultTravelBufferMins); first stop carries bufferMins 0 - detectScheduleConflicts: flag tight schedule when appointment gap < travelMins + bufferMins; never auto-move appointments - PATCH /api/routes/:routeId/reorder — validate permutation of current stops, persist new stopOrder (two-pass to avoid unique collision), re-estimate legs, re-apply buffers, recompute totals - Return route + per-stop conflict flags + hasConflicts/conflictCount on /daily, /optimize, /reorder - Unit tests for detectScheduleConflicts + recomputeLegsForOrder; UAT_PLAYBOOK §4.17 Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 24 +++- src/__tests__/routeOptimization.test.ts | 151 +++++++++++++++++++ src/routes/routes.ts | 184 +++++++++++++++++++++++- src/services/routeOptimization.ts | 100 +++++++++++++ 4 files changed, 455 insertions(+), 4 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 0267135..d7623f1 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -366,7 +366,7 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-16.1 | Fetch daily route (auto-create draft) | As **manager**, `GET /api/routes/daily?staffId={groomerId}&date=YYYY-MM-DD` for a date with no existing route | 200 OK; body `{ route, stops }`. `route.status` is `"draft"`, `route.staffId`/`routeDate` match, `stops` is `[]`. Re-calling returns the same route row (no duplicate) | -| TC-API-16.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). Each stop carries `bufferMins` (default 15) | +| TC-API-16.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). The first stop carries `bufferMins: 0` (no predecessor); every later stop carries `bufferMins` = `businessSettings.defaultTravelBufferMins` (default 15). Response also includes `hasConflicts` / `conflictCount` and each stop a `conflict` object (GRO-2156, see §4.17) | | TC-API-16.3 | Re-optimize replaces prior order | As manager, run TC-API-16.2 twice | Second call returns 200; stops fully replaced (no duplicate `route_stops`, `stopOrder` still contiguous 1..N), `optimizedAt` refreshed | | TC-API-16.4 | Skips un-geocoded appointments | As manager, optimize a day where one appointment's client has no coordinates | 200 OK; that appointment is absent from `stops` and listed under `skipped[]` with `reason: "client address is not geocoded"`; a corresponding entry appears in `warnings[]` | | TC-API-16.5 | Empty / single-stop day | As manager, optimize a date with 0 (or 1) geocoded appointments | 200 OK; `route.status: "optimized"`, `totalTravelMins: 0`, `totalDistanceKm: "0.00"`. For 1 stop, `stops` has one entry with `travelMinsFromPrev: null` | @@ -377,6 +377,28 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes | TC-API-16.10 | Manager must supply staffId | As manager, `POST /api/routes/optimize` body `{ "date": "YYYY-MM-DD" }` (no staffId) | 400 `{ error: "staffId is required" }` | | TC-API-16.11 | Invalid date rejected | `GET /api/routes/daily?staffId=...&date=06-08-2026` (wrong format) | 400 validation error (`date must be YYYY-MM-DD`) | +### 4.17 Route Optimization — Travel Buffer + Reorder (GRO-2156, Phase 2.2) + +Builds on §4.16. After optimization each consecutive leg carries a travel `bufferMins` (= `businessSettings.defaultTravelBufferMins`, default 15; the first stop is `0`). The API derives a per-stop **`conflict`** object at read time on `GET /api/routes/daily`, `POST /api/routes/optimize`, and `PATCH /api/routes/:routeId/reorder`: + +- `conflict.scheduleGapMins` — minutes between the previous appointment's `endTime` and this appointment's `startTime` (null for the first stop) +- `conflict.requiredGapMins` — `travelMinsFromPrev + bufferMins` (null for the first stop) +- `conflict.shortfallMins` — `requiredGapMins − scheduleGapMins` (positive ⇒ tight) +- `conflict.hasConflict` — true when `shortfallMins > 0` ("tight schedule"); appointments are **never auto-moved**, only flagged + +`PATCH /api/routes/:routeId/reorder` accepts `{ "stopOrder": ["", …] }` (every current stop id, exactly once, first-to-last), persists the new `stopOrder`, re-estimates each leg's travel offline for the new adjacency, re-applies buffers, recomputes route totals, and returns the route with refreshed conflict flags. **Auth: manager (any route) or groomer (own route only).** + +| ID | Scenario | Steps | Expected | +|----|----------|-------|----------| +| TC-API-17.1 | Conflict flags on optimize | As manager, optimize a day with ≥2 geocoded appointments whose times are close together | 200 OK; top-level `hasConflicts` (bool) + `conflictCount` (int). First stop `conflict.hasConflict:false` with null gap fields. A later stop whose `scheduleGapMins < travelMinsFromPrev + bufferMins` has `conflict.hasConflict:true` and positive `shortfallMins` | +| TC-API-17.2 | No false conflict on a roomy schedule | Optimize a day where appointment gaps comfortably exceed travel + buffer | 200 OK; `hasConflicts:false`, `conflictCount:0`, every `conflict.shortfallMins ≤ 0` | +| TC-API-17.3 | Reorder persists new order | As manager, take an optimized route, `PATCH /api/routes/{routeId}/reorder` with the stop ids in a new order | 200 OK; `stops` returned in the requested order with contiguous `stopOrder` 1..N; first stop `travelMinsFromPrev:null`/`bufferMins:0`, others recomputed; `route.totalTravelMins`/`totalDistanceKm` updated | +| TC-API-17.4 | Reorder re-flags conflicts | Reorder so a far-apart pair becomes adjacent | 200 OK; `conflict` flags recomputed for the new adjacency (`hasConflicts`/`conflictCount` reflect the new order) | +| TC-API-17.5 | Reorder validation — wrong stop set | `PATCH …/reorder` with a missing, extra, duplicate, or unknown stop id | 400 with an explanatory `error` (e.g. "must list every stop exactly once", "unknown stop id", "duplicate stop id") | +| TC-API-17.6 | Reorder unknown route | `PATCH /api/routes/{randomUuid}/reorder` with any body | 404 `{ error: "Route not found" }` | +| TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` | +| TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | + ## Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/routeOptimization.test.ts b/src/__tests__/routeOptimization.test.ts index 49877d4..706924a 100644 --- a/src/__tests__/routeOptimization.test.ts +++ b/src/__tests__/routeOptimization.test.ts @@ -4,6 +4,8 @@ import { estimateLeg, nearestNeighborOrder, optimizeRoute, + detectScheduleConflicts, + recomputeLegsForOrder, MAX_STOPS_PER_ROUTE, type RouteStopInput, } from "../services/routeOptimization.js"; @@ -182,3 +184,152 @@ describe("optimizeRoute — >25 stop chunking", () => { expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length); }); }); + +describe("detectScheduleConflicts", () => { + const at = (iso: string) => new Date(iso); + + it("returns no conflict and null gaps for an empty or single-stop route", () => { + expect(detectScheduleConflicts([])).toEqual([]); + const one = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 15, + }, + ]); + expect(one).toEqual([ + { + hasConflict: false, + scheduleGapMins: null, + requiredGapMins: null, + shortfallMins: null, + }, + ]); + }); + + it("flags a tight schedule when gap < travel + buffer", () => { + // Stop 1 ends 10:00, stop 2 starts 10:20 → 20min gap. Travel 15 + buffer 15 + // = 30 required → shortfall 10 → conflict. + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T10:20:00Z"), + appointmentEndTime: at("2026-06-08T11:00:00Z"), + travelMinsFromPrev: 15, + bufferMins: 15, + }, + ]); + expect(flags[0]!.hasConflict).toBe(false); + expect(flags[1]).toEqual({ + hasConflict: true, + scheduleGapMins: 20, + requiredGapMins: 30, + shortfallMins: 10, + }); + }); + + it("does not flag when the gap comfortably covers travel + buffer", () => { + // 90min gap, 15 travel + 15 buffer = 30 required → 60 slack → no conflict. + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T11:30:00Z"), + appointmentEndTime: at("2026-06-08T12:00:00Z"), + travelMinsFromPrev: 15, + bufferMins: 15, + }, + ]); + expect(flags[1]).toEqual({ + hasConflict: false, + scheduleGapMins: 90, + requiredGapMins: 30, + shortfallMins: -60, + }); + }); + + it("treats a null travelMinsFromPrev as zero travel", () => { + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T10:05:00Z"), + appointmentEndTime: at("2026-06-08T11:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 15, + }, + ]); + // 5min gap vs 0 travel + 15 buffer = 15 required → conflict, shortfall 10. + expect(flags[1]!.hasConflict).toBe(true); + expect(flags[1]!.requiredGapMins).toBe(15); + expect(flags[1]!.shortfallMins).toBe(10); + }); + + it("flags overlapping appointments (negative gap) as conflicts", () => { + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T09:30:00Z"), + appointmentEndTime: at("2026-06-08T10:30:00Z"), + travelMinsFromPrev: 10, + bufferMins: 15, + }, + ]); + expect(flags[1]!.scheduleGapMins).toBe(-30); + expect(flags[1]!.hasConflict).toBe(true); + expect(flags[1]!.shortfallMins).toBe(55); + }); +}); + +describe("recomputeLegsForOrder", () => { + it("returns null travel for an empty or single-point order", () => { + expect(recomputeLegsForOrder([])).toEqual([]); + expect(recomputeLegsForOrder([{ latitude: 40, longitude: -74 }])).toEqual([ + { travelMinsFromPrev: null, travelDistanceKmFromPrev: null }, + ]); + }); + + it("estimates each leg for the fixed given order without reordering", () => { + const pts = [ + { latitude: 0, longitude: 0 }, + { latitude: 0, longitude: 1 }, + { latitude: 0, longitude: 2 }, + ]; + const legs = recomputeLegsForOrder(pts); + expect(legs).toHaveLength(3); + expect(legs[0]).toEqual({ + travelMinsFromPrev: null, + travelDistanceKmFromPrev: null, + }); + // Each leg equals estimateLeg between adjacent points (no optimization). + const e01 = estimateLeg(pts[0]!, pts[1]!); + const e12 = estimateLeg(pts[1]!, pts[2]!); + expect(legs[1]).toEqual({ + travelMinsFromPrev: e01.mins, + travelDistanceKmFromPrev: e01.distanceKm, + }); + expect(legs[2]).toEqual({ + travelMinsFromPrev: e12.mins, + travelDistanceKmFromPrev: e12.distanceKm, + }); + }); +}); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index e9a1d41..3bf905a 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -19,7 +19,10 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js"; import { optimizeRoute, resolveRouteGoogleApiKey, + detectScheduleConflicts, + recomputeLegsForOrder, type RouteStopInput, + type StopConflictFlags, } from "../services/routeOptimization.js"; export const routesRouter = new Hono(); @@ -34,6 +37,11 @@ const optimizeBodySchema = z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), }); +const reorderBodySchema = z.object({ + // New visiting order expressed as routeStops.id values, first-to-last. + stopOrder: z.array(z.string().uuid()).min(1), +}); + /** * Resolves the target staffId for the request and enforces the groomer-own / * manager authorization rule. Groomers may only act on their own route; if a @@ -96,6 +104,31 @@ async function loadRouteStops(db: ReturnType, routeId: string) { .orderBy(asc(routeStops.stopOrder)); } +type LoadedRouteStop = Awaited>[number]; + +/** + * Annotates persisted stops with "tight schedule" conflict flags for the + * frontend. Conflicts are derived at read time from the live appointment times, + * persisted travel estimates and buffers — never auto-resolved by moving stops. + */ +function annotateConflicts(stops: LoadedRouteStop[]): { + stops: Array; + hasConflicts: boolean; + conflictCount: number; +} { + const flags = detectScheduleConflicts( + stops.map((s) => ({ + appointmentStartTime: s.appointmentStartTime, + appointmentEndTime: s.appointmentEndTime, + travelMinsFromPrev: s.travelMinsFromPrev, + bufferMins: s.bufferMins, + })) + ); + const annotated = stops.map((s, i) => ({ ...s, conflict: flags[i]! })); + const conflictCount = flags.filter((f) => f.hasConflict).length; + return { stops: annotated, hasConflicts: conflictCount > 0, conflictCount }; +} + /** * GET /api/routes/daily?staffId=&date= * Fetches (creating a draft if absent) the daily route for a groomer, with all @@ -130,7 +163,13 @@ routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => { } const stops = await loadRouteStops(db, route!.id); - return c.json({ route, stops }); + const annotated = annotateConflicts(stops); + return c.json({ + route, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, + }); }); /** @@ -262,7 +301,9 @@ routesRouter.post( s.travelDistanceKmFromPrev == null ? null : s.travelDistanceKmFromPrev.toFixed(2), - bufferMins, + // Buffer applies between consecutive stops; the first stop has no + // predecessor, so it carries no travel buffer. + bufferMins: i === 0 ? 0 : bufferMins, })) ); } @@ -271,9 +312,12 @@ routesRouter.post( }); const stops = await loadRouteStops(db, route.id); + const annotated = annotateConflicts(stops); return c.json({ route, - stops, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, provider: optimized.provider, chunked: optimized.chunked, subRouteCount: optimized.subRouteCount, @@ -282,3 +326,137 @@ routesRouter.post( }); } ); + +/** + * PATCH /api/routes/:routeId/reorder { stopOrder: string[] } + * Persists a manual stop order (array of routeStops.id, first-to-last), then + * re-runs the buffer logic: each leg's travel is re-estimated for the new + * adjacency, the default travel buffer is re-applied between consecutive stops, + * route totals are recomputed, and tight-schedule conflicts are re-flagged. + * Appointments are never moved. Auth: groomer (own route) or manager. + */ +routesRouter.patch( + "/:routeId/reorder", + zValidator("json", reorderBodySchema), + async (c) => { + const db = getDb(); + const routeId = c.req.param("routeId"); + if (!z.string().uuid().safeParse(routeId).success) { + return c.json({ error: "routeId must be a UUID" }, 400); + } + const { stopOrder: newOrderIds } = c.req.valid("json"); + + const [route] = await db + .select() + .from(groomerRoutes) + .where(eq(groomerRoutes.id, routeId)); + if (!route) { + return c.json({ error: "Route not found" }, 404); + } + + // Reuse the groomer-own / manager authorization rule against the route owner. + const resolved = resolveTargetStaffId(c.get("staff"), route.staffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + + const existing = await db + .select({ + id: routeStops.id, + latitude: routeStops.latitude, + longitude: routeStops.longitude, + }) + .from(routeStops) + .where(eq(routeStops.routeId, routeId)); + + // The new order must be an exact permutation of the route's current stops. + const existingIds = new Set(existing.map((s) => s.id)); + if (newOrderIds.length !== existing.length) { + return c.json( + { + error: `stopOrder must list every stop exactly once (expected ${existing.length}, got ${newOrderIds.length})`, + }, + 400 + ); + } + const seen = new Set(); + for (const id of newOrderIds) { + if (!existingIds.has(id)) { + return c.json({ error: `unknown stop id: ${id}` }, 400); + } + if (seen.has(id)) { + return c.json({ error: `duplicate stop id: ${id}` }, 400); + } + seen.add(id); + } + + const [settings] = await db.select().from(businessSettings).limit(1); + const bufferMins = settings?.defaultTravelBufferMins ?? 15; + + const byId = new Map(existing.map((s) => [s.id, s])); + const legs = recomputeLegsForOrder( + newOrderIds.map((id) => { + const s = byId.get(id)!; + return { latitude: s.latitude, longitude: s.longitude }; + }) + ); + + const totalTravelMins = legs.reduce( + (sum, l) => sum + (l.travelMinsFromPrev ?? 0), + 0 + ); + const totalDistanceKm = + Math.round( + legs.reduce((sum, l) => sum + (l.travelDistanceKmFromPrev ?? 0), 0) * 100 + ) / 100; + + const now = new Date(); + await db.transaction(async (tx) => { + // Two-pass update: park stopOrder in a non-colliding negative range first + // so the unique(routeId, stopOrder) constraint never trips mid-reorder. + for (let i = 0; i < newOrderIds.length; i++) { + await tx + .update(routeStops) + .set({ stopOrder: -(i + 1), updatedAt: now }) + .where(eq(routeStops.id, newOrderIds[i]!)); + } + for (let i = 0; i < newOrderIds.length; i++) { + const leg = legs[i]!; + await tx + .update(routeStops) + .set({ + stopOrder: i + 1, + travelMinsFromPrev: leg.travelMinsFromPrev, + travelDistanceKmFromPrev: + leg.travelDistanceKmFromPrev == null + ? null + : leg.travelDistanceKmFromPrev.toFixed(2), + bufferMins: i === 0 ? 0 : bufferMins, + updatedAt: now, + }) + .where(eq(routeStops.id, newOrderIds[i]!)); + } + await tx + .update(groomerRoutes) + .set({ + totalTravelMins, + totalDistanceKm: totalDistanceKm.toFixed(2), + updatedAt: now, + }) + .where(eq(groomerRoutes.id, routeId)); + }); + + const [updatedRoute] = await db + .select() + .from(groomerRoutes) + .where(eq(groomerRoutes.id, routeId)); + const stops = await loadRouteStops(db, routeId); + const annotated = annotateConflicts(stops); + return c.json({ + route: updatedRoute, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, + }); + } +); diff --git a/src/services/routeOptimization.ts b/src/services/routeOptimization.ts index 14de121..53ffe90 100644 --- a/src/services/routeOptimization.ts +++ b/src/services/routeOptimization.ts @@ -411,3 +411,103 @@ export async function resolveRouteGoogleApiKey( const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim(); return fromEnv ? fromEnv : null; } + +// ─── Travel buffer & schedule-conflict logic (GRO-2156, Phase 2.2) ─────────── + +/** A single stop's timing inputs for schedule-conflict detection. */ +export interface ScheduleStopTiming { + /** Scheduled appointment start. */ + appointmentStartTime: Date; + /** Scheduled appointment end. */ + appointmentEndTime: Date; + /** Travel minutes into this stop from the previous one (null for first). */ + travelMinsFromPrev: number | null; + /** Configured buffer minutes before this stop. */ + bufferMins: number; +} + +/** Conflict annotation for one stop, surfaced for the frontend to display. */ +export interface StopConflictFlags { + /** True when the schedule gap is too tight for travel + buffer. */ + hasConflict: boolean; + /** Minutes between the previous appointment's end and this one's start. + * Null for the first stop (no predecessor). */ + scheduleGapMins: number | null; + /** travelMinsFromPrev + bufferMins. Null for the first stop. */ + requiredGapMins: number | null; + /** requiredGapMins − scheduleGapMins; positive when the schedule is tight. + * Null for the first stop. */ + shortfallMins: number | null; +} + +const MS_PER_MIN = 60_000; + +/** + * Detects "tight schedule" conflicts between consecutive stops, in visiting + * order. A conflict exists when the real gap between the previous appointment's + * end and this appointment's start is smaller than the time needed to travel + * plus the configured buffer (`travelMinsFromPrev + bufferMins`). + * + * This only *flags* conflicts — appointments are never moved. The first stop + * has no predecessor and is therefore always conflict-free. + */ +export function detectScheduleConflicts( + stops: ScheduleStopTiming[] +): StopConflictFlags[] { + return stops.map((s, i) => { + if (i === 0) { + return { + hasConflict: false, + scheduleGapMins: null, + requiredGapMins: null, + shortfallMins: null, + }; + } + const prev = stops[i - 1]!; + const scheduleGapMins = Math.round( + (s.appointmentStartTime.getTime() - prev.appointmentEndTime.getTime()) / + MS_PER_MIN + ); + const requiredGapMins = (s.travelMinsFromPrev ?? 0) + s.bufferMins; + const shortfallMins = requiredGapMins - scheduleGapMins; + return { + hasConflict: shortfallMins > 0, + scheduleGapMins, + requiredGapMins, + shortfallMins, + }; + }); +} + +/** A coordinate used when recomputing legs for a fixed (manually chosen) order. */ +export interface OrderedPoint { + latitude: number; + longitude: number; +} + +/** Recomputed per-leg travel for a fixed stop order. */ +export interface RecomputedLeg { + /** Null for the first stop. */ + travelMinsFromPrev: number | null; + /** Null for the first stop. Kilometres, 2-dp. */ + travelDistanceKmFromPrev: number | null; +} + +/** + * Recomputes per-leg travel estimates for a *fixed* visiting order (e.g. after a + * manual reorder). Unlike {@link optimizeRoute} this does not reorder anything — + * it walks the given order and estimates each leg offline via {@link estimateLeg} + * so a manual drag does not consume Google Directions quota. + */ +export function recomputeLegsForOrder(points: OrderedPoint[]): RecomputedLeg[] { + return points.map((p, i) => { + if (i === 0) { + return { travelMinsFromPrev: null, travelDistanceKmFromPrev: null }; + } + const est = estimateLeg(points[i - 1]!, p); + return { + travelMinsFromPrev: est.mins, + travelDistanceKmFromPrev: est.distanceKm, + }; + }); +}