289 lines
8.2 KiB
TypeScript
289 lines
8.2 KiB
TypeScript
/**
|
|
* 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<Record<Weekday, TimeRange[]>>;
|
|
/**
|
|
* 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<Record<Weekday, SlotTemplate[]>>;
|
|
/** 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<Record<Method, MethodConfig>>;
|
|
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<string, number> };
|
|
}
|
|
|
|
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[];
|
|
}
|