From f3fb44f4019f071f48d9cc0382d50cfff07e653e Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 10 May 2026 14:26:28 +0100 Subject: [PATCH] fix: port slot grouping fixes across all booking journeys UserBookingModal (reschedule): add lunch protection filtering, validation guard preventing invalid start>end unavailable blocks, composite each keys, and simplified available/unavailable rendering (lunch is invisible to users). BookingCreateModal (admin): add validation guard for unavailable blocks and fix composite each keys. Admin lunch protection behavior (30min min + amber warning) preserved. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../account/UserBookingModal.svelte | 50 ++++++++++++++++--- .../admin/BookingCreateModal.svelte | 16 +++--- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 5513fc1..21db644 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -9,6 +9,7 @@ import * as Label from '$lib/components/ui/label'; import DatePicker from '$lib/components/booking/DatePicker.svelte'; import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; + import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection'; interface Props { open: boolean; @@ -44,6 +45,35 @@ selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) || 0 ); + const rescheduleLunchProtection = $derived(() => { + if (!rescheduleDate || !rescheduleWorkingHours || !rescheduleAvailableHours || totalDuration === 0) { + return new Map(); + } + + const dateStr = rescheduleDate.toString(); + const dayWorkingHours = rescheduleWorkingHours[dateStr]; + const dayAvailableHours = rescheduleAvailableHours[dateStr]; + + if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) { + return new Map(); + } + + const existingBookings = extractBookedSlots( + dayWorkingHours.startTime, + dayWorkingHours.endTime, + dayAvailableHours.slots + ); + + return getLunchProtectionForSlots( + dayWorkingHours.startTime, + dayWorkingHours.endTime, + existingBookings, + totalDuration, + 15, // 15 minute slot intervals + false // User journey - requires 1h minimum + ); + }); + let isFutureBooking = $derived( selectedBooking ? new Date(selectedBooking.start_time) > new Date() : false ); @@ -256,7 +286,11 @@ return slots; } - function generateGroupedTimeSlots(duration: number, date: CalendarDate): Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> { + function generateGroupedTimeSlots( + duration: number, + date: CalendarDate, + lunchProtection: Map = new Map() + ): Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> { if (!rescheduleWorkingHours) return []; const dateStr = date.toString(); const dayWH = rescheduleWorkingHours[dateStr]; @@ -282,12 +316,15 @@ for (let m = startMin; m < endMin; m += 15) { const timeStr = `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; - const isAvailable = availableSlots.includes(timeStr); + const isAvailable = availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked; if (isAvailable) { if (currentUnavailableStart !== null) { const groupEnd = calculatePreviousTime(timeStr); - grouped.push({ type: 'unavailable', startTime: lastAvailableEnd || currentUnavailableStart, endTime: groupEnd, isGrouped: true }); + const unavailableStartTime = lastAvailableEnd || currentUnavailableStart; + if (unavailableStartTime && timeToMinutes(unavailableStartTime) < timeToMinutes(groupEnd)) { + grouped.push({ type: 'unavailable', startTime: unavailableStartTime, endTime: groupEnd, isGrouped: true }); + } currentUnavailableStart = null; } const slotEnd = calculateEndTime(timeStr, duration); @@ -303,7 +340,8 @@ if (currentUnavailableStart !== null) { const lastAvail = grouped.filter((s) => s.type === 'available').pop(); const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0; - if (timeToMinutes(currentUnavailableStart) < endMin && lastAvailEnd < endMin) { + const unavailableStartMinutes = timeToMinutes(currentUnavailableStart); + if (unavailableStartMinutes < endMin && lastAvailEnd < endMin) { grouped.push({ type: 'unavailable', startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart, endTime: dayWH.endTime, isGrouped: true }); } } @@ -631,10 +669,10 @@ {#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen}

We're closed on this day

{:else} - {@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate)} + {@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate, rescheduleLunchProtection())} {#if grouped.length > 0}
- {#each grouped as slot (slot.startTime + slot.endTime)} + {#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)} {#if slot.type === 'available'}