datewise/app/engine/runsheet.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

99 lines
3.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Driver dispatch: daily run sheet, stop ordering and route links.
*
* Ordering: by slot window first (promises come first), then nearest-neighbour
* within each window starting from the previous stop. This is a pragmatic
* heuristic, not an optimal VRP solver — export to a route optimiser for large fleets.
*/
import type { Booking, LocationConfig } from "./types";
import { haversineKm } from "./zones";
export interface RunStop {
seq: number;
booking: Booking;
window: string;
legKm: number | null;
}
export interface RunSheet {
locationId: string;
date: string;
stops: RunStop[];
totalKm: number | null;
unlocated: Booking[];
mapsUrls: string[];
}
export function buildRunSheet(bookings: Booking[], loc: LocationConfig, date: string): RunSheet {
const todays = bookings.filter(
(b) => b.method === "delivery" && b.locationId === loc.id && b.date === date && b.status === "confirmed",
);
const located = todays.filter((b) => b.address?.lat != null && b.address?.lng != null);
const unlocated = todays.filter((b) => !located.includes(b));
const windows = new Map<string, Booking[]>();
for (const b of located) {
const w = `${b.slotStart}-${b.slotEnd}`;
windows.set(w, [...(windows.get(w) ?? []), b]);
}
const ordered: RunStop[] = [];
let cur = { lat: loc.lat ?? 0, lng: loc.lng ?? 0 };
let total = 0;
const hasOrigin = loc.lat != null && loc.lng != null;
for (const w of [...windows.keys()].sort()) {
const pool = [...windows.get(w)!];
while (pool.length) {
let best = 0;
let bestD = Infinity;
pool.forEach((b, i) => {
const d = haversineKm(cur.lat, cur.lng, b.address!.lat!, b.address!.lng!);
if (d < bestD) (bestD = d), (best = i);
});
const [b] = pool.splice(best, 1);
const leg = hasOrigin || ordered.length ? bestD : null;
if (leg != null) total += leg;
ordered.push({ seq: ordered.length + 1, booking: b, window: w, legKm: leg });
cur = { lat: b.address!.lat!, lng: b.address!.lng! };
}
}
return {
locationId: loc.id,
date,
stops: ordered,
totalKm: ordered.length ? Math.round(total * 10) / 10 : null,
unlocated,
mapsUrls: mapsUrls(loc, ordered),
};
}
/** Google Maps directions links, chunked to the 9-waypoint limit of the URL API. */
export function mapsUrls(loc: LocationConfig, stops: RunStop[]): string[] {
if (!stops.length) return [];
const pt = (b: Booking) => `${b.address!.lat},${b.address!.lng}`;
const origin = loc.lat != null ? `${loc.lat},${loc.lng}` : encodeURIComponent(loc.address);
const urls: string[] = [];
let from = origin;
for (let i = 0; i < stops.length; i += 10) {
const chunk = stops.slice(i, i + 10);
const dest = pt(chunk[chunk.length - 1].booking);
const way = chunk.slice(0, -1).map((s) => pt(s.booking)).join("|");
urls.push(
`https://www.google.com/maps/dir/?api=1&origin=${from}&destination=${dest}` +
(way ? `&waypoints=${encodeURIComponent(way)}` : "") +
`&travelmode=driving`,
);
from = dest;
}
return urls;
}
/** RFC 4180 CSV with a UTF-8 BOM so Excel opens accents/Tamil text correctly. */
export function toCsv(rows: Record<string, unknown>[], columns?: string[]): string {
const cols = columns ?? Object.keys(rows[0] ?? {});
const esc = (v: unknown) => {
const s = v == null ? "" : String(v);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return "" + [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\r\n");
}