feat: configurable booking lead time in admin (default 48h)

Adds leadTimeHours to HoursConfig. Slot generation, calendar minDate,
and pickup disabled-date precomputation all read from the config.
Admin hours page has a new input to adjust it without a redeploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-08 07:48:13 -04:00
parent 134705792c
commit f969e5d242
6 changed files with 44 additions and 14 deletions

View File

@ -281,6 +281,26 @@ function HoursEditor() {
</div> </div>
))} ))}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
Booking lead time (hours)
</label>
<p style={{ fontSize: '0.78rem', color: '#888', marginBottom: '0.5rem' }}>
Minimum notice required before a delivery or pickup can be booked. Dates within this window are greyed out on the calendar.
</p>
<input
type="number"
className="input is-small"
min={1}
max={168}
step={1}
value={config.leadTimeHours ?? 48}
onChange={(e) => setConfig((prev) => prev ? { ...prev, leadTimeHours: Math.max(1, parseInt(e.target.value) || 48) } : prev)}
style={{ width: 100 }}
/>
<span style={{ marginLeft: 8, fontSize: '0.82rem', color: '#666' }}>hours</span>
</div>
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}> <label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
Delivery arrival window (minutes) Delivery arrival window (minutes)

View File

@ -196,7 +196,8 @@ export default function CartDrawer() {
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ') const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
const canQuote = street.trim() && city.trim() const canQuote = street.trim() && city.trim()
const pickupSlots = useMemo(() => getPickupSlots(pickupDate, hoursConfig), [pickupDate, hoursConfig]) const pickupSlots = useMemo(() => getPickupSlots(pickupDate, hoursConfig), [pickupDate, hoursConfig])
const todayStr = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10) const leadMs = (hoursConfig?.leadTimeHours ?? 48) * 60 * 60 * 1000
const todayStr = new Date(Date.now() + leadMs).toISOString().slice(0, 10)
const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10) const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
// Unit price — uses selected variation price if set, otherwise product default // Unit price — uses selected variation price if set, otherwise product default
@ -221,13 +222,13 @@ export default function CartDrawer() {
// Pre-compute pickup disabled dates (closed days) for the next 90 days // Pre-compute pickup disabled dates (closed days) for the next 90 days
const pickupDisabledDates = useMemo(() => { const pickupDisabledDates = useMemo(() => {
const disabled = new Set<string>() const disabled = new Set<string>()
const base = Date.now() + 24 * 60 * 60 * 1000 // start from tomorrow const base = Date.now() + leadMs
for (let i = 0; i < 90; i++) { for (let i = 0; i < 90; i++) {
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10) const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
if (getPickupSlots(d, hoursConfig).length === 0) disabled.add(d) if (getPickupSlots(d, hoursConfig).length === 0) disabled.add(d)
} }
return disabled return disabled
}, [hoursConfig]) }, [hoursConfig, leadMs])
const CT_TAX_RATE = 0.0635 const CT_TAX_RATE = 0.0635
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0) const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)

View File

@ -23,7 +23,6 @@ interface Props {
type SlotState = 'idle' | 'loading' | 'loaded' | 'error' type SlotState = 'idle' | 'loading' | 'loaded' | 'error'
const minDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const maxDate = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10) const maxDate = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
export default function DeliveryDatePicker({ address, tier, value, onChange }: Props) { export default function DeliveryDatePicker({ address, tier, value, onChange }: Props) {
@ -32,6 +31,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
const [drive, setDrive] = useState(0) const [drive, setDrive] = useState(0)
const [status, setStatus] = useState<SlotState>('idle') const [status, setStatus] = useState<SlotState>('idle')
const [errMsg, setErrMsg] = useState('') const [errMsg, setErrMsg] = useState('')
const [leadTimeHours, setLeadTimeHours] = useState(48)
// Dates that returned 0 slots — shown as strikethrough on the calendar // Dates that returned 0 slots — shown as strikethrough on the calendar
const [noSlotDates, setNoSlotDates] = useState<Set<string>>(new Set()) const [noSlotDates, setNoSlotDates] = useState<Set<string>>(new Set())
@ -39,14 +39,18 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
// Busy dates from CalDAV — shown with an orange dot // Busy dates from CalDAV — shown with an orange dot
const [busyDates, setBusyDates] = useState<Set<string>>(new Set()) const [busyDates, setBusyDates] = useState<Set<string>>(new Set())
const minDate = new Date(Date.now() + leadTimeHours * 60 * 60 * 1000).toISOString().slice(0, 10)
// Pre-mark all closed delivery days from admin hours config // Pre-mark all closed delivery days from admin hours config
useEffect(() => { useEffect(() => {
fetch(`${BASE}/api/hours`) fetch(`${BASE}/api/hours`)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then((config: HoursConfig | null) => { .then((config: HoursConfig | null) => {
if (!config) return if (!config) return
const lead = config.leadTimeHours ?? 48
setLeadTimeHours(lead)
const closed = new Set<string>() const closed = new Set<string>()
const start = Date.now() + 24 * 60 * 60 * 1000 const start = Date.now() + lead * 60 * 60 * 1000
for (let i = 0; i < 90; i++) { for (let i = 0; i < 90; i++) {
const dateStr = new Date(start + i * 86400_000).toISOString().slice(0, 10) const dateStr = new Date(start + i * 86400_000).toISOString().slice(0, 10)
const dow = new Date(`${dateStr}T12:00:00Z`).getUTCDay() const dow = new Date(`${dateStr}T12:00:00Z`).getUTCDay()

View File

@ -11,10 +11,12 @@ export interface HoursConfig {
delivery: WeekHours delivery: WeekHours
pickup: WeekHours pickup: WeekHours
deliveryWindowMinutes: number // customer-facing arrival window (email + Square) deliveryWindowMinutes: number // customer-facing arrival window (email + Square)
leadTimeHours: number // minimum hours in advance a slot can be booked
} }
export const DEFAULT_HOURS: HoursConfig = { export const DEFAULT_HOURS: HoursConfig = {
deliveryWindowMinutes: 60, deliveryWindowMinutes: 60,
leadTimeHours: 48,
delivery: { delivery: {
'0': { open: 480, close: 1020 }, // Sun 8:00 AM 5:00 PM '0': { open: 480, close: 1020 }, // Sun 8:00 AM 5:00 PM
'1': null, // Mon closed '1': null, // Mon closed

View File

@ -17,6 +17,7 @@ export function getHoursConfig(): HoursConfig {
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) }, delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) }, pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes, deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes,
leadTimeHours: saved.leadTimeHours ?? DEFAULT_HOURS.leadTimeHours,
} }
} catch { } catch {
return DEFAULT_HOURS return DEFAULT_HOURS

View File

@ -111,7 +111,8 @@ export async function getAvailableSlots(
const closeTotalMin = hours.close const closeTotalMin = hours.close
const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60) const closeUTC = etToUtc(date, Math.floor(hours.close / 60), hours.close % 60)
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000) const leadMs = (hoursConfig ?? DEFAULT_HOURS).leadTimeHours * 60 * 60_000
const cutoffUTC = new Date(Date.now() + leadMs)
for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) { for (let arrivalTotalMin = openTotalMin; arrivalTotalMin < closeTotalMin; arrivalTotalMin += SLOT_STEP) {
const arrivalH = Math.floor(arrivalTotalMin / 60) const arrivalH = Math.floor(arrivalTotalMin / 60)
@ -120,7 +121,7 @@ export async function getAvailableSlots(
const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000) const departUTC = new Date(arrivalUTC.getTime() - driveMinutes * 60_000)
const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000) const returnUTC = new Date(departUTC.getTime() + blockMinutes * 60_000)
if (arrivalUTC < cutoffUTC) continue // enforce 24-hour lead time if (arrivalUTC < cutoffUTC) continue // enforce lead time
if (returnUTC > closeUTC) break if (returnUTC > closeUTC) break
// Reject any slot whose full driver block overlaps an existing event // Reject any slot whose full driver block overlaps an existing event
@ -164,14 +165,15 @@ export function getPickupSlots(date: string, hoursConfig?: HoursConfig): TimeSlo
const openTotalMins = hours.open const openTotalMins = hours.open
const closeTotalMins = hours.close const closeTotalMins = hours.close
const cutoffUTC = new Date(Date.now() + 24 * 60 * 60_000) const leadMs = (hoursConfig ?? DEFAULT_HOURS).leadTimeHours * 60 * 60_000
const cutoffUTC = new Date(Date.now() + leadMs)
const slots: TimeSlot[] = [] const slots: TimeSlot[] = []
for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) { for (let total = openTotalMins; total < closeTotalMins; total += SLOT_STEP) {
const h = Math.floor(total / 60) const h = Math.floor(total / 60)
const m = total % 60 const m = total % 60
const slotUTC = etToUtc(date, h, m) const slotUTC = etToUtc(date, h, m)
if (slotUTC < cutoffUTC) continue // enforce 24-hour lead time if (slotUTC < cutoffUTC) continue // enforce lead time
slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) }) slots.push({ startISO: slotUTC.toISOString(), label: fmtLabel(h, m) })
} }
return slots return slots