chris 21ebb9667b Add 'estore/' from commit 'e34dfc397c94025670baa2b73b482c01f3033a6a'
git-subtree-dir: estore
git-subtree-mainline: 746868d720b9be1003a2f783b7a12d526d8eea60
git-subtree-split: e34dfc397c94025670baa2b73b482c01f3033a6a
2026-04-13 19:22:23 -04:00

179 lines
6.8 KiB
TypeScript

import type { BusyBlock } from './caldav'
import type { DeliveryTier } from './delivery'
import { JOB_MINUTES, SHOP_LAT, SHOP_LNG, geocode, drivingInfo } from './delivery'
import { DEFAULT_HOURS } from './hours-config'
import type { HoursConfig } from './hours-config'
const SLOT_STEP = 30 // minutes between candidate slots
export interface TimeSlot {
startISO: string // UTC ISO string for the start of the customer window
label: string // e.g. "10:00 AM"
}
/** ET offset in minutes from UTC. EDT = -240, EST = -300 */
function etOffsetMinutes(date: Date): number {
const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', timeZoneName: 'short' })
const parts = fmt.formatToParts(date)
const tzName = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''
return tzName === 'EDT' ? -240 : -300
}
/** Build a UTC Date from a local (ET) date string + hour + minute */
function etToUtc(dateStr: string, hour: number, minute: number): Date {
const midnight = new Date(`${dateStr}T00:00:00Z`)
const offset = etOffsetMinutes(midnight)
return new Date(midnight.getTime() + (-offset + hour * 60 + minute) * 60_000)
}
function fmtLabel(hour: number, minute: number): string {
const ampm = hour < 12 ? 'AM' : 'PM'
const h = hour % 12 || 12
const m = String(minute).padStart(2, '0')
return `${h}:${m} ${ampm}`
}
function overlaps(slotStart: Date, slotEnd: Date, busy: BusyBlock): boolean {
const bs = new Date(busy.start).getTime()
const be = new Date(busy.end).getTime()
const ss = slotStart.getTime()
const se = slotEnd.getTime()
return ss < be && se > bs
}
/** Day of week (0=Sun…6=Sat) for a date string in America/New_York */
function etDayOfWeek(dateStr: string): number {
// noon ET is always on the same calendar date as the date string
const noonET = etToUtc(dateStr, 12, 0)
return noonET.getUTCDay()
}
/**
* Returns available delivery start times for a given date.
*
* When destLat/destLng are provided, the function also enforces a
* travel-time gap from the previous calendar event's location to the
* new delivery address — so you won't be double-booked if you're still
* driving back from a previous job. If there is no prior event, the
* origin is assumed to be the shop.
*/
export async function getAvailableSlots(
date: string, // "YYYY-MM-DD"
driveMinutes: number, // shop → destination (one way)
tier: DeliveryTier,
busyBlocks: BusyBlock[],
destLat?: number, // destination coordinates (enables travel-time chaining)
destLng?: number,
hoursConfig?: HoursConfig,
): Promise<TimeSlot[]> {
const dow = etDayOfWeek(date)
const hours = (hoursConfig ?? DEFAULT_HOURS).delivery[String(dow)]
if (!hours) return [] // closed today
const blockMinutes = driveMinutes + JOB_MINUTES[tier] + driveMinutes
// ── Pre-compute drive times from each unique previous-event location ──────
// Keyed by location string; value = minutes to drive to destination.
// Falls back to shop→dest drive time for events without a location.
const driveFromLoc = new Map<string, number>()
if (destLat !== undefined && destLng !== undefined) {
const uniqueLocs = Array.from(new Set(
busyBlocks.map((b) => b.location).filter((l): l is string => !!l)
))
await Promise.all(uniqueLocs.map(async (loc) => {
try {
const coords = await geocode(loc)
if (coords) {
const { minutes } = await drivingInfo(coords.lat, coords.lng, destLat!, destLng!)
driveFromLoc.set(loc, minutes)
} else {
driveFromLoc.set(loc, driveMinutes) // geocode failed → assume shop distance
}
} catch {
driveFromLoc.set(loc, driveMinutes)
}
}))
}
// Sort busy blocks by end time for efficient "previous event" lookup
const sortedByEnd = [...busyBlocks].sort(
(a, b) => new Date(a.end).getTime() - new Date(b.end).getTime()
)
const slots: TimeSlot[] = []
// Iterate over ARRIVAL times on :00/:30 boundaries.
// Departure = arrival - driveMinutes; conflict checking uses the full
// driver block (depart shop → return shop).
// hours.open / hours.close are already in minutes-from-midnight.
const openTotalMin = hours.open
const closeTotalMin = hours.close
const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60)
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000)
for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) {
const arrivalH = Math.floor(arrivalTotalMin / 60)
const arrivalM = arrivalTotalMin % 60
const arrivalUTC = etToUtc(date, arrivalH, arrivalM)
const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000)
const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000)
if (arrivalUTC < cutoffUTC) continue // enforce 24-hour lead time
if (returnUTC > closeUTC) break
// Reject any slot whose full driver block overlaps an existing event
if (busyBlocks.some((b) => overlaps(departUTC, returnUTC, b))) continue
// ── Travel-time chaining ────────────────────────────────────────────
if (destLat !== undefined && destLng !== undefined) {
const departMs = departUTC.getTime()
const prevBlock = sortedByEnd.slice().reverse().find(
(b) => new Date(b.end).getTime() <= departMs
)
if (prevBlock) {
const travelMins = prevBlock.location
? (driveFromLoc.get(prevBlock.location) ?? driveMinutes)
: driveMinutes
const prevEndMs = new Date(prevBlock.end).getTime()
const earliestDepart = prevEndMs + travelMins * 60_000
if (departMs < earliestDepart) continue
}
}
slots.push({ startISO: arrivalUTC.toISOString(), label: fmtLabel(arrivalH, arrivalM) })
}
return slots
}
/**
* Returns pickup time slots for a given date.
* No calendar conflict check — multiple pickups can overlap.
*/
export function getPickupSlots(date: string, hoursConfig?: HoursConfig): TimeSlot[] {
if (!date) return []
const dow = etDayOfWeek(date)
const hours = (hoursConfig ?? DEFAULT_HOURS).pickup[String(dow)]
if (!hours) return []
const openTotalMins = hours.open
const closeTotalMins = hours.close
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000)
const slots: TimeSlot[] = []
for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) {
const h = Math.floor(total / 60)
const m = total % 60
const slotUTC = etToUtc(date, h, m)
if (slotUTC < cutoffUTC) continue // enforce 24-hour lead time
slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) })
}
return slots
}