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 { 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() 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 }