/** * Datewise scheduling engine — shared types. * * Conventions * - Calendar dates are local to the location: "YYYY-MM-DD". * - Clock times are local wall-clock: "HH:MM" (24h). * - Instants are UTC ISO strings or epoch milliseconds. * - Money is integer minor units (cents) in the shop currency. */ export type Method = "pickup" | "delivery" | "shipping"; export const METHODS: Method[] = ["pickup", "delivery", "shipping"]; export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday export interface TimeRange { start: string; // "HH:MM" end: string; // "HH:MM" (exclusive) } export interface SlotTemplate extends TimeRange { /** Overrides MethodConfig.slotCapacity for this slot. */ capacity?: number | null; /** Extra fee for this slot (e.g. evening delivery), minor units. */ surcharge?: number; } export interface MethodConfig { enabled: boolean; /** Operating hours per weekday. Missing/empty weekday = closed. */ weeklyHours: Partial>; /** * How slots are produced: * - "generated": split operating hours into `slotMinutes` slots * - "explicit": use `slots[weekday]` * - "dateOnly": customer picks a date only (typical for shipping dispatch) */ slotMode: "generated" | "explicit" | "dateOnly"; slotMinutes?: number; slots?: Partial>; /** Minimum preparation time before the slot starts. */ prepMinutes: number; /** "elapsed" counts wall-clock time; "operating" counts only open hours. */ prepMode: "elapsed" | "operating"; /** At/after this local time, same-day service is no longer bookable. */ sameDayCutoff?: string | null; /** At/after this local time, next-day service is no longer bookable. */ nextDayCutoff?: string | null; /** Days ahead customers can book (inclusive of today = day 0). */ horizonDays: number; dailyCapacity?: number | null; slotCapacity?: number | null; } export interface DateException { date: string; // YYYY-MM-DD /** Closed for the listed methods (or all when omitted). */ closed?: boolean; /** Replacement hours for that day (special opening). */ hours?: TimeRange[]; methods?: Method[]; note?: string; } export interface DeliveryZone { id: string; name: string; /** Postal patterns: exact ("N2H 5Z6"), prefix ("N2H*"), bare FSA ("N2H"), or range ("N2A..N2H"). */ postalPatterns?: string[]; /** Straight-line radius in km from the location. */ radiusKm?: number | null; /** Driving-distance limit in km (requires a maps provider). */ drivingKm?: number | null; fee: number; /** Order subtotal at or above which the fee is waived. */ freeOver?: number | null; minOrder?: number | null; } export interface LocationConfig { id: string; shopifyLocationId?: string; name: string; address: string; timezone: string; // IANA, e.g. "America/Toronto" lat?: number; lng?: number; enabled: boolean; priority: number; // lower = preferred methods: Partial>; exceptions: DateException[]; zones: DeliveryZone[]; pickupInstructions?: string; } /** Peak / festival day — the "rush control" feature. */ export interface PeakDay { id: string; name: string; // "Diwali", "Mother's Day" dates: string[]; // YYYY-MM-DD locationIds?: string[]; // all when omitted methods?: Method[]; // all when omitted dailyCapacity?: number | null; // replaces normal daily capacity slotCapacity?: number | null; // replaces normal slot capacity extraPrepMinutes?: number; /** Earlier cutoff: bookings for the peak date close `daysBefore` days earlier at `time`. */ cutoff?: { daysBefore: number; time: string } | null; surcharge?: number; // per order, minor units /** When full, offer a waitlist instead of a dead end. */ waitlist?: boolean; /** Customer-facing banner text. */ message?: string; } export type RuleTarget = | { type: "product"; values: string[] } | { type: "variant"; values: string[] } | { type: "collection"; values: string[] } | { type: "vendor"; values: string[] } | { type: "productType"; values: string[] } | { type: "tag"; values: string[] }; export type RuleAction = | { type: "prohibitMethods"; methods: Method[] } | { type: "allowOnlyMethods"; methods: Method[] } | { type: "restrictLocations"; locationIds: string[] } | { type: "addPrep"; minutes: number } | { type: "blockWeekdays"; weekdays: Weekday[] } | { type: "blockDates"; dates: string[] } | { type: "dateWindow"; from?: string; to?: string } /** * Perishable stock: the item can only be fulfilled between * (arrival + minDaysAfterArrival) and (arrival + shelfLifeDays) for some arrival. */ | { type: "freshness"; arrivals: { date: string; locationIds?: string[] }[]; minDaysAfterArrival: number; shelfLifeDays: number; } | { type: "requireScheduling" }; export interface ProductRule { id: string; name: string; enabled: boolean; priority: number; // lower runs first (only matters for display/explanations) target: RuleTarget; action: RuleAction; /** Customer-facing explanation. */ message?: string; } export interface CartItem { productId: string; variantId: string; title: string; quantity: number; price: number; // unit, minor units collectionIds?: string[]; vendor?: string; productType?: string; tags?: string[]; grams?: number; } export interface Cart { items: CartItem[]; subtotal?: number; } /** Bookings + active holds, keyed by location/method/date. */ export interface UsageSnapshot { [key: string]: { day: number; slots: Record }; } export interface ShopSettings { /** Mixed cart prep policy. MVP: longest wins. */ mixedPrepPolicy: "longest" | "sum"; holdMinutes: number; weekStartsOn: Weekday; /** Customer self-reschedule policy. */ reschedule: { enabled: boolean; /** Must be at least this many hours before the booked slot starts. */ minHoursBefore: number; maxChanges: number; /** Allow switching location/method, or only date/slot. */ allowMethodChange: boolean; }; reminders: ReminderPolicy[]; } export interface ReminderPolicy { id: string; channel: "email" | "sms" | "whatsapp"; /** Minutes before slot start. */ minutesBefore: number; methods?: Method[]; template: string; enabled: boolean; } export interface Booking { id: string; orderId?: string; orderName?: string; status: "held" | "confirmed" | "cancelled" | "fulfilled" | "needs_review"; method: Method; locationId: string; date: string; slotStart?: string; slotEnd?: string; timezone: string; startUtc?: string; endUtc?: string; customer?: { name?: string; email?: string; phone?: string }; address?: { line1?: string; city?: string; postal?: string; lat?: number; lng?: number }; items?: { title: string; quantity: number }[]; rescheduleCount?: number; peakDayId?: string | null; fee?: number; ruleVersion?: string; } export type ReasonCode = | "LOCATION_DISABLED" | "METHOD_DISABLED" | "METHOD_PROHIBITED_BY_PRODUCT" | "LOCATION_PROHIBITED_BY_PRODUCT" | "OUTSIDE_DELIVERY_AREA" | "BELOW_MINIMUM_ORDER" | "OUT_OF_STOCK_AT_LOCATION" | "BEYOND_HORIZON" | "CLOSED_WEEKLY" | "CLOSED_EXCEPTION" | "BLOCKED_BY_PRODUCT" | "NOT_FRESH_YET" | "PAST_FRESHNESS" | "NO_STOCK_ARRIVAL" | "PAST_CUTOFF" | "PEAK_CUTOFF" | "PREP_TIME" | "DAY_FULL" | "SLOT_FULL" | "SLOT_IN_PAST"; export interface Reason { code: ReasonCode; message: string; ruleId?: string; } export interface SlotResult { id: string; // "HH:MM-HH:MM" start: string; end: string; startUtc: string; endUtc: string; available: boolean; remaining: number | null; // null = unlimited surcharge: number; waitlist: boolean; reasons: Reason[]; } export interface DateResult { date: string; weekday: Weekday; available: boolean; remaining: number | null; peak?: { id: string; name: string; message?: string } | null; surcharge: number; waitlist: boolean; reasons: Reason[]; slots: SlotResult[]; } export interface AvailabilityResult { locationId: string; method: Method; timezone: string; dateOnly: boolean; prepMinutes: number; earliestReadyUtc: string; blockers: Reason[]; // whole method/location unavailable dates: DateResult[]; }