git-subtree-dir: estore git-subtree-mainline: 746868d720b9be1003a2f783b7a12d526d8eea60 git-subtree-split: e34dfc397c94025670baa2b73b482c01f3033a6a
179 lines
6.8 KiB
TypeScript
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
|
|
}
|