datewise/app/engine/types.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

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[];
}