import { businessSettings, decryptSecret, type Db } from "@groombook/db"; import type { FetchLike } from "./geocoding.js"; /** * Route optimization service (GRO-2155, Phase 2.1 of Route Optimization). * * Given a groomer's geocoded stops for a day, produces an optimized visiting * order plus per-leg and total travel estimates. Two strategies: * * - {@link optimizeWithGoogle}: Google Maps Directions API with * `optimizeWaypoints: true` (real road durations/distances), used when a * Google Maps API key is configured. * - {@link nearestNeighborOrder}: an offline nearest-neighbor TSP heuristic over * great-circle distance, used as the default free / no-API-key fallback. * * Both strategies share the same public {@link optimizeRoute} orchestrator, * which also handles the >25-stop edge case by chunking into sub-routes (the * Google Directions waypoint cap) and surfacing a warning. */ /** Google Directions allows origin + destination + up to 23 waypoints = 25 * points per request. We cap a sub-route at 25 stops and chunk beyond that. */ export const MAX_STOPS_PER_ROUTE = 25; /** Average driving speed (km/h) used to convert distance into travel minutes in * the offline heuristic. Tuned for mixed urban/suburban mobile-groomer routes. */ export const AVG_SPEED_KMH = 40; /** Multiplier applied to great-circle distance to approximate real road * distance in the offline heuristic (straight-line underestimates driving). */ export const ROAD_CIRCUITY_FACTOR = 1.3; const EARTH_RADIUS_KM = 6371; /** A geocoded stop to be ordered. `appointmentId` ties it back to the schedule. */ export interface RouteStopInput { appointmentId: string; latitude: number; longitude: number; } /** A single stop in the optimized order, with travel from the previous stop. */ export interface OptimizedStop { appointmentId: string; latitude: number; longitude: number; /** Null for the first stop of the whole route. */ travelMinsFromPrev: number | null; /** Null for the first stop of the whole route. Kilometres, 2-dp. */ travelDistanceKmFromPrev: number | null; } export type RouteOptimizationProvider = "google" | "nearest_neighbor"; export interface OptimizedRoute { provider: RouteOptimizationProvider; stops: OptimizedStop[]; totalTravelMins: number; /** Kilometres, rounded to 2 decimal places. */ totalDistanceKm: number; /** True when the route was split into multiple sub-routes (>25 stops). */ chunked: boolean; subRouteCount: number; /** Non-fatal advisories for the caller to surface to the user. */ warnings: string[]; } export interface OptimizeRouteOptions { /** Google Maps API key. When absent, the nearest-neighbor heuristic is used. */ googleApiKey?: string | null; /** Injectable fetch for testing the Google path. Defaults to global fetch. */ fetchImpl?: FetchLike; } const defaultFetch: FetchLike = (input, init) => (globalThis.fetch as unknown as FetchLike)(input, init); // ─── Geometry helpers ─────────────────────────────────────────────────────── function toRadians(deg: number): number { return (deg * Math.PI) / 180; } /** Great-circle distance between two coordinates, in kilometres. */ export function haversineKm( a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number } ): number { const dLat = toRadians(b.latitude - a.latitude); const dLon = toRadians(b.longitude - a.longitude); const lat1 = toRadians(a.latitude); const lat2 = toRadians(b.latitude); const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); } /** Round to 2 decimal places, returning a finite number. */ function round2(n: number): number { return Math.round(n * 100) / 100; } /** * Estimate a road travel leg from the great-circle distance between two points. * Applies a circuity factor for distance and a fixed average speed for time. */ export function estimateLeg( a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number } ): { distanceKm: number; mins: number } { const straight = haversineKm(a, b); const distanceKm = straight * ROAD_CIRCUITY_FACTOR; const mins = (distanceKm / AVG_SPEED_KMH) * 60; return { distanceKm: round2(distanceKm), mins: Math.round(mins) }; } // ─── Nearest-neighbor heuristic ───────────────────────────────────────────── /** * Orders points greedily: start at `startIndex`, then repeatedly visit the * nearest unvisited point (great-circle distance). Returns indices into the * input array in visiting order. Deterministic ties broken by lowest index. */ export function nearestNeighborOrder( points: Array<{ latitude: number; longitude: number }>, startIndex = 0 ): number[] { const n = points.length; if (n <= 1) return points.map((_, i) => i); const visited = new Array(n).fill(false); const order: number[] = [startIndex]; visited[startIndex] = true; let current = startIndex; for (let step = 1; step < n; step++) { let best = -1; let bestDist = Infinity; for (let j = 0; j < n; j++) { if (visited[j]) continue; const d = haversineKm(points[current]!, points[j]!); if (d < bestDist) { bestDist = d; best = j; } } visited[best] = true; order.push(best); current = best; } return order; } /** Orders one chunk (<= MAX_STOPS_PER_ROUTE) via nearest-neighbor. */ function optimizeChunkNearestNeighbor( stops: RouteStopInput[] ): RouteStopInput[] { const order = nearestNeighborOrder(stops, 0); return order.map((i) => stops[i]!); } // ─── Google Directions ────────────────────────────────────────────────────── const GOOGLE_DIRECTIONS_URL = "https://maps.googleapis.com/maps/api/directions/json"; interface GoogleDirectionsResponse { status: string; error_message?: string; routes?: Array<{ waypoint_order?: number[]; legs?: Array<{ duration?: { value?: number }; distance?: { value?: number }; }>; }>; } /** * Orders one chunk via the Google Directions API with `optimizeWaypoints=true`. * * The first stop is fixed as both origin and destination (a closed tour); the * remaining stops are passed as optimizable waypoints. We keep the optimized * forward order and drop the final return-to-origin leg, yielding an open route * whose per-leg durations/distances come from real road data. */ async function optimizeChunkGoogle( stops: RouteStopInput[], apiKey: string, fetchImpl: FetchLike ): Promise<{ stops: RouteStopInput[]; legsMeters: number[]; legsSeconds: number[] }> { if (stops.length <= 1) { return { stops: [...stops], legsMeters: [], legsSeconds: [] }; } const origin = stops[0]!; const waypoints = stops.slice(1); const url = new URL(GOOGLE_DIRECTIONS_URL); url.searchParams.set("origin", `${origin.latitude},${origin.longitude}`); url.searchParams.set("destination", `${origin.latitude},${origin.longitude}`); url.searchParams.set( "waypoints", "optimize:true|" + waypoints.map((w) => `${w.latitude},${w.longitude}`).join("|") ); url.searchParams.set("key", apiKey); const res = await fetchImpl(url.toString()); if (!res.ok) { throw new Error( `Google Directions request failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as GoogleDirectionsResponse; if (body.status !== "OK" || !body.routes || body.routes.length === 0) { throw new Error( `Google Directions returned status ${body.status}${ body.error_message ? `: ${body.error_message}` : "" }` ); } const route = body.routes[0]!; const waypointOrder = route.waypoint_order ?? waypoints.map((_, i) => i); const legs = route.legs ?? []; // Ordered stops: origin first, then waypoints in the optimized order. const orderedStops: RouteStopInput[] = [ origin, ...waypointOrder.map((i) => waypoints[i]!), ]; // legs[k] is the travel into orderedStops[k+1]. Drop the trailing return leg // (orderedStops.length-1 legs describe the open route). const legsMeters: number[] = []; const legsSeconds: number[] = []; for (let k = 0; k < orderedStops.length - 1; k++) { const leg = legs[k]; legsMeters.push(leg?.distance?.value ?? 0); legsSeconds.push(leg?.duration?.value ?? 0); } return { stops: orderedStops, legsMeters, legsSeconds }; } // ─── Orchestration ────────────────────────────────────────────────────────── function chunk(items: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < items.length; i += size) { out.push(items.slice(i, i + size)); } return out; } /** * Optimizes a full day's stops into a single visiting order with travel * metrics. Uses Google Directions when `googleApiKey` is provided, otherwise the * offline nearest-neighbor heuristic. Routes longer than * {@link MAX_STOPS_PER_ROUTE} stops are split into sub-routes and a warning is * emitted; sub-routes are stitched end-to-end, with the boundary leg estimated * from great-circle distance. */ export async function optimizeRoute( inputStops: RouteStopInput[], options: OptimizeRouteOptions = {} ): Promise { const fetchImpl = options.fetchImpl ?? defaultFetch; const useGoogle = Boolean(options.googleApiKey); const provider: RouteOptimizationProvider = useGoogle ? "google" : "nearest_neighbor"; const warnings: string[] = []; if (inputStops.length === 0) { return { provider, stops: [], totalTravelMins: 0, totalDistanceKm: 0, chunked: false, subRouteCount: 0, warnings, }; } const chunks = chunk(inputStops, MAX_STOPS_PER_ROUTE); const chunked = chunks.length > 1; if (chunked) { warnings.push( `Route has ${inputStops.length} stops, exceeding the ${MAX_STOPS_PER_ROUTE}-stop optimization limit. Split into ${chunks.length} sub-routes; review the order at sub-route boundaries.` ); } const ordered: OptimizedStop[] = []; let prev: RouteStopInput | null = null; for (const group of chunks) { let groupStops: RouteStopInput[]; let legDistanceKm: (i: number) => number; let legMins: (i: number) => number; if (useGoogle) { try { const result = await optimizeChunkGoogle( group, options.googleApiKey!, fetchImpl ); groupStops = result.stops; legDistanceKm = (i) => round2(result.legsMeters[i]! / 1000); legMins = (i) => Math.round(result.legsSeconds[i]! / 60); } catch (err) { // Google failed mid-optimization — degrade to the offline heuristic for // this run rather than failing the whole request. warnings.push( `Google Directions unavailable; used offline heuristic: ${ err instanceof Error ? err.message : String(err) }` ); groupStops = optimizeChunkNearestNeighbor(group); legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm; legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins; } } else { groupStops = optimizeChunkNearestNeighbor(group); legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm; legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins; } for (let i = 0; i < groupStops.length; i++) { const stop = groupStops[i]!; if (prev === null) { // Very first stop of the whole route. ordered.push({ appointmentId: stop.appointmentId, latitude: stop.latitude, longitude: stop.longitude, travelMinsFromPrev: null, travelDistanceKmFromPrev: null, }); } else if (i === 0) { // First stop of a non-initial chunk: estimate the boundary leg. const est = estimateLeg(prev, stop); ordered.push({ appointmentId: stop.appointmentId, latitude: stop.latitude, longitude: stop.longitude, travelMinsFromPrev: est.mins, travelDistanceKmFromPrev: est.distanceKm, }); } else { ordered.push({ appointmentId: stop.appointmentId, latitude: stop.latitude, longitude: stop.longitude, travelMinsFromPrev: legMins(i - 1), travelDistanceKmFromPrev: legDistanceKm(i - 1), }); } prev = stop; } } const totalTravelMins = ordered.reduce( (sum, s) => sum + (s.travelMinsFromPrev ?? 0), 0 ); const totalDistanceKm = round2( ordered.reduce((sum, s) => sum + (s.travelDistanceKmFromPrev ?? 0), 0) ); return { provider, stops: ordered, totalTravelMins, totalDistanceKm, chunked, subRouteCount: chunks.length, warnings, }; } // ─── Google API key resolution ────────────────────────────────────────────── /** * Resolves the Google Maps API key for route optimization from * `businessSettings.googleMapsApiKey` (decrypted at rest) or, as a development * convenience, the `GOOGLE_MAPS_API_KEY` env var. Returns `null` when no usable * key exists, in which case callers fall back to the offline heuristic. */ export async function resolveRouteGoogleApiKey( db: Db, decrypt: (ciphertext: string) => string = decryptSecret ): Promise { const [settings] = await db.select().from(businessSettings).limit(1); const stored = settings?.googleMapsApiKey?.trim(); if (stored) { try { const decrypted = decrypt(stored).trim(); if (decrypted) return decrypted; } catch (err) { console.warn( `Failed to decrypt googleMapsApiKey for route optimization; using offline heuristic: ${ err instanceof Error ? err.message : String(err) }` ); } } 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, }; }); }