diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 5976a46..4fa9a75 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -14,7 +14,7 @@ import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { authStore } from '$lib/stores/auth.svelte'; import { toast } from 'svelte-sonner'; - import { SvelteMap, SvelteDate } from 'svelte/reactivity'; + import { SvelteDate } from 'svelte/reactivity'; // Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; @@ -349,7 +349,7 @@ if (!selectedDate) return; const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; - availableHoursCache.delete(monthKey); + delete availableHoursCache[monthKey]; await fetchHoursForMonth(selectedDate); } @@ -400,38 +400,6 @@ } } - // =============== ADD: Lunch Protection =============== - const lunchProtectionStatus = $derived(() => { - if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) { - return new Map(); - } - - const dateStr = selectedDate.toString(); - const dayWorkingHours = workingHours[dateStr]; - const dayAvailableHours = availableHours[dateStr]; - - if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) { - return new Map(); - } - - // Extract existing bookings from the gap between working hours and available hours - const existingBookings = extractBookedSlots( - dayWorkingHours.startTime, - dayWorkingHours.endTime, - dayAvailableHours.slots - ); - - // Get lunch protection status for all slots - return getLunchProtectionForSlots( - dayWorkingHours.startTime, - dayWorkingHours.endTime, - existingBookings, - getTotalDuration(), - 15, // 15 minute slot intervals - false // User journey - requires 1h minimum - ); - }); - // =============== Working Hours & Available Hours =============== let workingHours = $state(false); let loadingAvailableHours = $state(false); - const workingHoursCache = new SvelteMap< + let workingHoursCache: Record< string, Record - >(); + > = {}; - const availableHoursCache = new SvelteMap< + let availableHoursCache: Record< string, Record }> - >(); - - $effect(() => { - return () => { - workingHoursCache.clear(); - availableHoursCache.clear(); - }; - }); + > = {}; // Initialize date boundaries const today = new SvelteDate(); @@ -480,25 +441,100 @@ let placeholder = $state(minDate); let userNavigatedCalendar = $state(false); + let bookingFlowAutoSelectDone = $state(false); $effect(() => { fetchServices(); }); - // Preload 3 months on first render to prevent snap-back during navigation + // Track which months are currently being fetched (prevents duplicate requests) + let loadingMonths: Record = {}; + + // Preload current + next month on first render; subsequent months fetched individually let initialLoadDone = $state(false); $effect(() => { if (!initialLoadDone) { - fetchHoursRange(placeholder, 3); + // Pre-seed cache for current + next month + for (let i = 0; i < 2; i++) { + let mYear = placeholder.year; + let mMonth = placeholder.month + i; + while (mMonth > 12) { + mMonth -= 12; + mYear++; + } + const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; + if (!(key in workingHoursCache)) { + workingHoursCache[key] = null as unknown as Record; + availableHoursCache[key] = null as unknown as Record }>; + loadingMonths[key] = true; + } + } + fetchHoursRange(placeholder, 2); initialLoadDone = true; } }); - // Fetch additional months when navigating beyond preloaded range + // Safety net: fetch silently when navigating to an uncached month + // (uses skipLoadingFlags=true to prevent layout shift / scroll snap) $effect(() => { const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`; - if (initialLoadDone && !workingHoursCache.has(monthKey)) { - fetchHoursForMonth(placeholder); + if (initialLoadDone && !(monthKey in workingHoursCache)) { + fetchHoursForMonth(placeholder, true); + } + }); + + // Data-driven auto-selection: auto-select the first available date when data loads + $effect(() => { + if (workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingFlowAutoSelectDone) { + bookingFlowAutoSelectDone = true; + + const currentDate = new SvelteDate(); + const maxDateJs = new SvelteDate( + maxCalendarDate.year, + maxCalendarDate.month - 1, + maxCalendarDate.day + ); + + const daysDifference = Math.floor( + (maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24) + ); + const daysToCheck = Math.min(daysDifference, 180); + + for (let i = 1; i <= daysToCheck; i++) { + const nextDate = new SvelteDate(currentDate); + nextDate.setDate(currentDate.getDate() + i); + const dateStr = nextDate.toISOString().split('T')[0]; + + if (workingHours[dateStr]?.isOpen) { + const calDate = new CalendarDate( + nextDate.getFullYear(), + nextDate.getMonth() + 1, + nextDate.getDate() + ); + if (!isDateUnavailable(calDate)) { + selectedDate = calDate; + if (!userNavigatedCalendar) { + placeholder = new CalendarDate( + nextDate.getFullYear(), + nextDate.getMonth() + 1, + 1 + ); + } + return; + } + } + } + + const tomorrow = new SvelteDate(); + tomorrow.setDate(tomorrow.getDate() + 1); + selectedDate = new CalendarDate( + tomorrow.getFullYear(), + tomorrow.getMonth() + 1, + tomorrow.getDate() + ); + if (!userNavigatedCalendar) { + placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1); + } } }); @@ -550,18 +586,27 @@ mYear++; } const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; - workingHoursCache.set(key, whMap); - availableHoursCache.set(key, ahMap); + workingHoursCache[key] = whMap; + availableHoursCache[key] = ahMap; + delete loadingMonths[key]; } - workingHours = whMap; - availableHours = ahMap; - - if (!selectedDate) { - setDefaultSelectedDate(whMap); - } + // MERGE instead of replace — preserves data from previously loaded months + workingHours = { ...workingHours, ...whMap }; + availableHours = { ...availableHours, ...ahMap }; } catch (error) { console.error('Failed to fetch hours:', error); + // Clean up loadingMonths for the range + for (let i = 0; i < months; i++) { + let mYear = startDate.year; + let mMonth = startDate.month + i; + while (mMonth > 12) { + mMonth -= 12; + mYear++; + } + const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; + delete loadingMonths[key]; + } if (!selectedDate) { selectedDate = minDate; } @@ -571,17 +616,25 @@ } } - async function fetchHoursForMonth(date: CalendarDate) { + async function fetchHoursForMonth(date: CalendarDate, skipLoadingFlags = false) { const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`; - if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) { - workingHours = workingHoursCache.get(monthKey)!; - availableHours = availableHoursCache.get(monthKey)!; + // Only use cache if the value is truthy (not a pre-seeded null placeholder) + if (workingHoursCache[monthKey] && availableHoursCache[monthKey]) { + // MERGE instead of replace — preserves data from other loaded months + workingHours = { ...workingHours, ...workingHoursCache[monthKey] }; + availableHours = { ...availableHours, ...availableHoursCache[monthKey] }; return; } - loadingWorkingHours = true; - loadingAvailableHours = true; + // Prevent duplicate concurrent requests for the same month + if (loadingMonths[monthKey]) return; + loadingMonths[monthKey] = true; + + if (!skipLoadingFlags) { + loadingWorkingHours = true; + loadingAvailableHours = true; + } try { const startOfMonth = new CalendarDate(date.year, date.month, 1); @@ -616,8 +669,8 @@ }; }); - workingHoursCache.set(monthKey, workingHoursMap); - workingHours = workingHoursMap; + workingHoursCache[monthKey] = workingHoursMap; + workingHours = { ...workingHours, ...workingHoursMap }; // Fetch available hours const availableHoursResponse = await fetch( @@ -640,75 +693,20 @@ }; }); - availableHoursCache.set(monthKey, availableHoursMap); - availableHours = availableHoursMap; - - if (!selectedDate) { - setDefaultSelectedDate(workingHoursMap); - } + availableHoursCache[monthKey] = availableHoursMap; + availableHours = { ...availableHours, ...availableHoursMap }; } catch (error) { console.error('Failed to fetch hours:', error); if (!selectedDate) { selectedDate = minDate; } } finally { - loadingWorkingHours = false; - loadingAvailableHours = false; - } - } - - function setDefaultSelectedDate( - hoursMap: Record - ) { - const currentDate = new SvelteDate(); - const maxDateJs = new SvelteDate( - maxCalendarDate.year, - maxCalendarDate.month - 1, - maxCalendarDate.day - ); - - const daysDifference = Math.floor( - (maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24) - ); - const daysToCheck = Math.min(daysDifference, 180); - - for (let i = 1; i <= daysToCheck; i++) { - const nextDate = new SvelteDate(currentDate); - nextDate.setDate(currentDate.getDate() + i); - const dateStr = nextDate.toISOString().split('T')[0]; - - if (hoursMap[dateStr]?.isOpen) { - const calDate = new CalendarDate( - nextDate.getFullYear(), - nextDate.getMonth() + 1, - nextDate.getDate() - ); - const duration = getTotalDuration() || 60; - const slots = generateAvailableTimeSlots(duration, calDate); - if (slots.length > 0) { - selectedDate = calDate; - if (!userNavigatedCalendar) { - placeholder = new CalendarDate( - nextDate.getFullYear(), - nextDate.getMonth() + 1, - 1 - ); - } - return; - } + delete loadingMonths[monthKey]; + if (!skipLoadingFlags) { + loadingWorkingHours = false; + loadingAvailableHours = false; } } - - const tomorrow = new SvelteDate(); - tomorrow.setDate(tomorrow.getDate() + 1); - selectedDate = new CalendarDate( - tomorrow.getFullYear(), - tomorrow.getMonth() + 1, - tomorrow.getDate() - ); - if (!userNavigatedCalendar) { - placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1); - } } // =============== Time Slot Generation =============== @@ -764,12 +762,12 @@ const [startHour, startMinute] = slot.startTime.split(':').map(Number); const [endHour, endMinute] = slot.endTime.split(':').map(Number); - let startTotalMinutes = startHour * 60 + startMinute; + let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15; const endTotalMinutes = endHour * 60 + endMinute; if (isToday) { const currentMinutes = now.getHours() * 60 + now.getMinutes(); - const minimumStartMinutes = currentMinutes + 120; + const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15; startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes); } @@ -791,10 +789,7 @@ function generateGroupedTimeSlots( duration: number, date: CalendarDate | undefined, - lunchProtection: Map< - string, - { isBlocked: boolean; showWarning: boolean; warningMessage?: string } - > = new Map() + lunchProtectionMap: Map = new Map() ): Array<{ type: 'available' | 'unavailable'; startTime: string; @@ -830,7 +825,7 @@ if (isToday) { const currentMinutes = now.getHours() * 60 + now.getMinutes(); - const minimumStartMinutes = currentMinutes + 120; + const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15; startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes); } @@ -845,7 +840,7 @@ const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; const isAvailable = - availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked; + availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked; if (isAvailable) { if (currentUnavailableStart !== null) { @@ -924,6 +919,11 @@ if (!dayHours) return true; if (!dayHours.isOpen) return true; + // No available hours data for this date = data not loaded = unavailable + if (!availableHours?.[dateStr]) return true; + // API returned empty slots = no availability at all + if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true; + if (selectedServices.length === 0) { return false; } @@ -932,8 +932,9 @@ const availableSlots = generateAvailableTimeSlots(duration, date); if (availableSlots.length === 0) return true; - const dayAvailableHours = availableHours?.[dateStr]; - if (dayAvailableHours?.slots) { + const dayAvailableHours = availableHours[dateStr]; + // dayAvailableHours.slots is already checked above, but keep this guard for safety + if (dayAvailableHours.slots) { const existingBookings = extractBookedSlots( dayHours.startTime, dayHours.endTime, @@ -979,26 +980,55 @@ // Only clear if we're on the date/time selection step if (currentStep === 2 && selectedDate) { const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; - availableHoursCache.delete(monthKey); + delete availableHoursCache[monthKey]; fetchHoursForMonth(selectedDate); } + // Reset date selection when services change so auto-select can re-run + bookingFlowAutoSelectDone = false; + selectedDate = undefined; selectedTime = null; } + // =============== Lunch Protection for Rendering =============== + function getLunchProtectionStatus() { + if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) { + return new Map< + string, + { isBlocked: boolean; showWarning: boolean; warningMessage?: string } + >(); + } + + const dateStr = selectedDate.toString(); + const dayWH = workingHours[dateStr]; + const dayAH = availableHours[dateStr]; + if (!dayWH?.isOpen || !dayAH?.slots) return new Map(); + + const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots); + return getLunchProtectionForSlots( + dayWH.startTime, + dayWH.endTime, + existingBookings, + getTotalDuration(), + 15, + false + ); + } + // Select a time slot with server-side re-validation async function selectTimeWithValidation(time: string) { selectedTime = time; await refreshAndValidateSlot(); } - // Re-fetch available hours and check if selectedTime is still available + // Re-fetch available hours silently and check if selectedTime is still available + // Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker) async function refreshAndValidateSlot() { if (!selectedDate || !selectedTime) return; const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; - availableHoursCache.delete(monthKey); - await fetchHoursForMonth(selectedDate); + delete availableHoursCache[monthKey]; + await fetchHoursForMonth(selectedDate, true); const dateStr = selectedDate.toString(); const dayAvailable = availableHours?.[dateStr]?.slots; @@ -1064,9 +1094,10 @@ // =============== Derived Values =============== const formattedTotalDuration = $derived(formatDuration(getTotalDuration())); + const lunchProtectionMap = $derived(getLunchProtectionStatus()); const groupedTimeSlots = $derived( currentStep === 2 && selectedServices.length > 0 && selectedDate - ? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionStatus()) + ? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionMap) : [] ); const formattedSelectedDate = $derived( @@ -1425,8 +1456,8 @@ selectedTime = null; }} onPlaceholderChange={(newPlaceholder) => { - userNavigatedCalendar = true; placeholder = newPlaceholder; + userNavigatedCalendar = true; }} /> {/if} @@ -1446,7 +1477,6 @@ onselect={(time) => { selectTimeWithValidation(time); }} - lunchProtectionStatus={lunchProtectionStatus()} /> {/if} diff --git a/frontend/src/lib/components/booking/DatePicker.svelte b/frontend/src/lib/components/booking/DatePicker.svelte index c545abf..38c1878 100644 --- a/frontend/src/lib/components/booking/DatePicker.svelte +++ b/frontend/src/lib/components/booking/DatePicker.svelte @@ -24,16 +24,28 @@
isDateUnavailable(d)} class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0" weekdayFormat="short" {minValue} {maxValue} locale="en-GB" onValueChange={(v: DateValue | undefined) => { - if (onchange) { + // Guard: bits-ui fires onValueChange(undefined) as an intermediate deselect + // before firing onValueChange(selectedDate). In controlled mode this causes + // the parent to briefly set selectedDate=undefined, which the Calendar then + // receives back, effectively canceling the new selection. Only propagate + // defined values to prevent the first-click-does-nothing issue. + if (onchange && v) { onchange(v as CalendarDate | undefined); } }} diff --git a/frontend/src/lib/components/booking/SelectedTimeSummary.svelte b/frontend/src/lib/components/booking/SelectedTimeSummary.svelte new file mode 100644 index 0000000..fa88f09 --- /dev/null +++ b/frontend/src/lib/components/booking/SelectedTimeSummary.svelte @@ -0,0 +1,44 @@ + + + +{#if protection?.showWarning || protection?.isBlocked} +
+
+ + + +
+
+ {protection?.isBlocked ? 'Lunch break conflict' : 'Lunch break warning'} +
+
+ {protection?.warningMessage} +
+
+
+
+{/if} +
+
+ {#if protection?.isBlocked}Booking conflict{:else if protection?.showWarning}Lunch warning{:else}Time selected{/if} +
+
+ {selectedDate} at {selectedTime} + {' — '} + {endTime} + {' ('}{duration} min) +
+
diff --git a/frontend/src/lib/components/booking/TimeSlotList.svelte b/frontend/src/lib/components/booking/TimeSlotList.svelte new file mode 100644 index 0000000..4be01e6 --- /dev/null +++ b/frontend/src/lib/components/booking/TimeSlotList.svelte @@ -0,0 +1,70 @@ + + +{#if slots.length > 0} +
+ {#each slots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)} + {#if slot.type === 'available'} + {@const p = protection.get(slot.startTime)} + {#if p?.isBlocked} + + {:else} + + {/if} + {:else} + + {/if} + {/each} +
+{:else} +

No available slots for this date

+{/if} diff --git a/frontend/src/lib/components/booking/TimeSlotPicker.svelte b/frontend/src/lib/components/booking/TimeSlotPicker.svelte index b464d60..3331fe2 100644 --- a/frontend/src/lib/components/booking/TimeSlotPicker.svelte +++ b/frontend/src/lib/components/booking/TimeSlotPicker.svelte @@ -7,8 +7,7 @@ groupedTimeSlots = [], selectedTime = null, formattedDate, - onselect, - lunchProtectionStatus = new Map() + onselect }: { date: CalendarDate | undefined; groupedTimeSlots?: Array<{ @@ -20,10 +19,6 @@ selectedTime?: string | null; formattedDate?: string; onselect?: (time: string) => void; - lunchProtectionStatus?: Map< - string, - { isBlocked: boolean; showWarning: boolean; warningMessage?: string } - >; } = $props(); function formatTime(time: string): string { @@ -54,52 +49,19 @@
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)} {#if slot.type === 'available'} - {@const protection = lunchProtectionStatus.get(slot.startTime)} - {#if protection?.isBlocked} - - - {:else} - - - {/if} + + {:else}