feat(GRO-2156): travel buffer + reorder endpoint (Phase 2.2) (#180)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Build & Push Docker Images (pull_request) Successful in 27s
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Build & Push Docker Images (pull_request) Successful in 27s
This commit was merged in pull request #180.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user