From 41666b07393ac86c855a151a465fc83433dee528 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 8 May 2026 22:44:18 +0100 Subject: [PATCH] fix: full reschedule implementation with real available slots - Fetch /api/scheduling/working-hours and /api/scheduling/available-hours for reschedule month - Generate grouped time slots using same logic as BookingFlow (available + unavailable with start-end times) - DatePicker uses isDateUnavailable based on real availability (no slots = unavailable) - Time slots show X - Y format (e.g. 9:30 AM - 10:00 AM) matching booking flow - Unavailable slots shown as disabled buttons - Today's slots respect 2-hour minimum notice buffer - Hours fetched on first reschedule open or calendar month change --- .../account/UserBookingModal.svelte | 245 +++++++++++++++--- 1 file changed, 212 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 63e8a92..5513fc1 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -8,7 +8,7 @@ import * as Textarea from '$lib/components/ui/textarea'; import * as Label from '$lib/components/ui/label'; import DatePicker from '$lib/components/booking/DatePicker.svelte'; - import type { Booking } from '$lib/types/booking'; + import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking'; interface Props { open: boolean; @@ -29,6 +29,10 @@ let rescheduleNotes = $state(''); let rescheduleSubmitting = $state(false); + let rescheduleWorkingHours = $state | null>(null); + let rescheduleAvailableHours = $state }> | null>(null); + let loadingRescheduleHours = $state(false); + const today = new Date(); const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate()); const maxDate = new Date(); @@ -103,6 +107,8 @@ rescheduleTime = ''; rescheduleNotes = ''; reschedulePlaceholder = minDate; + rescheduleWorkingHours = null; + rescheduleAvailableHours = null; }, 200); } else if (bookingId && !selectedBooking) { fetchBookingDetails(); @@ -138,12 +144,54 @@ } } + async function fetchRescheduleHours(date: CalendarDate) { + const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`; + loadingRescheduleHours = true; + try { + const startOfMonth = new CalendarDate(date.year, date.month, 1); + const endOfMonth = new CalendarDate(date.year, date.month, date.calendar.getDaysInMonth(date)); + const startStr = startOfMonth.toString(); + const endStr = endOfMonth.toString(); + + const [whRes, ahRes] = await Promise.all([ + fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`), + fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`) + ]); + + if (whRes.ok && ahRes.ok) { + const whData: WorkingHoursDay[] = await whRes.json(); + const ahData: AvailableHoursDay[] = await ahRes.json(); + + const whMap: Record = {}; + whData.forEach((d) => { whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; }); + + const ahMap: Record }> = {}; + ahData.forEach((d) => { ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }; }); + + rescheduleWorkingHours = whMap; + rescheduleAvailableHours = ahMap; + } + } catch (err) { + console.error('Failed to fetch reschedule hours:', err); + } finally { + loadingRescheduleHours = false; + } + } + function isDateUnavailable(date: DateValue): boolean { const d = date as CalendarDate; const jsDate = d.toDate(getLocalTimeZone()); const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - return jsDate < todayStart; + if (jsDate < todayStart) return true; + if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true; + if (!rescheduleWorkingHours) return false; + const dateStr = d.toString(); + const dayHours = rescheduleWorkingHours[dateStr]; + if (!dayHours || !dayHours.isOpen) return true; + if (totalDuration === 0) return false; + const slots = generateAvailableTimeSlots(totalDuration, d); + return slots.length === 0; } function formatTime(time: string): string { @@ -157,18 +205,111 @@ return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; } - function generateTimeSlots(): string[] { + function calculateEndTime(startTime: string, durationMinutes: number): string { + const [hours, minutes] = startTime.split(':').map(Number); + let total = hours * 60 + minutes + durationMinutes; + const h = Math.floor(total / 60); + const m = total % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + } + + function timeToMinutes(time: string): number { + const [h, m] = time.split(':').map(Number); + return h * 60 + m; + } + + function calculatePreviousTime(time: string): string { + const [h, m] = time.split(':').map(Number); + let total = h * 60 + m - 15; + return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; + } + + function generateAvailableTimeSlots(duration: number, date: CalendarDate): string[] { + if (!rescheduleWorkingHours || !rescheduleAvailableHours) return []; + const dateStr = date.toString(); + const dayWH = rescheduleWorkingHours[dateStr]; + const dayAH = rescheduleAvailableHours[dateStr]; + if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return []; + const slots: string[] = []; - for (let h = 8; h <= 18; h++) { - for (let m = 0; m < 60; m += 30) { - if (h === 18 && m > 0) break; - slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`); + const now = new SvelteDate(); + const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const isToday = date.compare(todayCal) === 0; + + for (const slot of dayAH.slots) { + const [sh, sm] = slot.startTime.split(':').map(Number); + const [eh, em] = slot.endTime.split(':').map(Number); + let startMin = sh * 60 + sm; + const endMin = eh * 60 + em; + + if (isToday) { + const currentMin = now.getHours() * 60 + now.getMinutes(); + startMin = Math.max(startMin, currentMin + 120); + } + + for (let m = startMin; m < endMin; m += 15) { + if (m + duration <= endMin) { + slots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`); + } } } return slots; } - const timeSlots = generateTimeSlots(); + function generateGroupedTimeSlots(duration: number, date: CalendarDate): Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> { + if (!rescheduleWorkingHours) return []; + const dateStr = date.toString(); + const dayWH = rescheduleWorkingHours[dateStr]; + if (!dayWH || !dayWH.isOpen) return []; + + const grouped: Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> = []; + const [sh, sm] = dayWH.startTime.split(':').map(Number); + const [eh, em] = dayWH.endTime.split(':').map(Number); + let startMin = sh * 60 + sm; + const endMin = eh * 60 + em; + + const now = new SvelteDate(); + const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const isToday = date.compare(todayCal) === 0; + if (isToday) { + const currentMin = now.getHours() * 60 + now.getMinutes(); + startMin = Math.max(startMin, currentMin + 120); + } + + const availableSlots = generateAvailableTimeSlots(duration, date); + let currentUnavailableStart: string | null = null; + let lastAvailableEnd: string | null = null; + + 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); + + if (isAvailable) { + if (currentUnavailableStart !== null) { + const groupEnd = calculatePreviousTime(timeStr); + grouped.push({ type: 'unavailable', startTime: lastAvailableEnd || currentUnavailableStart, endTime: groupEnd, isGrouped: true }); + currentUnavailableStart = null; + } + const slotEnd = calculateEndTime(timeStr, duration); + lastAvailableEnd = slotEnd; + grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd }); + } else { + if (currentUnavailableStart === null) { + currentUnavailableStart = timeStr; + } + } + } + + 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) { + grouped.push({ type: 'unavailable', startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart, endTime: dayWH.endTime, isGrouped: true }); + } + } + + return grouped; + } async function submitReschedule() { if (!selectedBooking || !rescheduleDate || !rescheduleTime) return; @@ -456,35 +597,71 @@ Request Reschedule -
- { rescheduleDate = d; rescheduleTime = ''; }} - onPlaceholderChange={(p) => { reschedulePlaceholder = p; }} - /> -
+ {#if loadingRescheduleHours} +
+

Loading available dates...

+
+ {:else} +
+ { rescheduleDate = d; rescheduleTime = ''; }} + onPlaceholderChange={(p) => { + reschedulePlaceholder = p; + if (!rescheduleWorkingHours) fetchRescheduleHours(p); + }} + /> +
+ {/if} {#if rescheduleDate} -
-
- {rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })} + {#if loadingRescheduleHours} +
+

Loading times...

-
- {#each timeSlots as slot (slot)} - - {/each} + {:else} +
+
+ {rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })} +
+ {#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen} +

We're closed on this day

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

No available slots

+ {/if} + {/if}
-
+ {/if} {/if}
@@ -544,6 +721,8 @@ rescheduleDate = undefined; rescheduleTime = ''; rescheduleNotes = ''; + } else if (!rescheduleWorkingHours) { + fetchRescheduleHours(reschedulePlaceholder); } }} >