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 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");
}