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
This commit is contained in:
2026-05-08 22:44:18 +01:00
parent 6d95eb6dcf
commit 41666b0739
@@ -8,7 +8,7 @@
import * as Textarea from '$lib/components/ui/textarea'; import * as Textarea from '$lib/components/ui/textarea';
import * as Label from '$lib/components/ui/label'; import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte'; 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 { interface Props {
open: boolean; open: boolean;
@@ -29,6 +29,10 @@
let rescheduleNotes = $state(''); let rescheduleNotes = $state('');
let rescheduleSubmitting = $state(false); let rescheduleSubmitting = $state(false);
let rescheduleWorkingHours = $state<Record<string, { isOpen: boolean; startTime: string; endTime: string }> | null>(null);
let rescheduleAvailableHours = $state<Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> | null>(null);
let loadingRescheduleHours = $state(false);
const today = new Date(); const today = new Date();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate()); const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new Date(); const maxDate = new Date();
@@ -103,6 +107,8 @@
rescheduleTime = ''; rescheduleTime = '';
rescheduleNotes = ''; rescheduleNotes = '';
reschedulePlaceholder = minDate; reschedulePlaceholder = minDate;
rescheduleWorkingHours = null;
rescheduleAvailableHours = null;
}, 200); }, 200);
} else if (bookingId && !selectedBooking) { } else if (bookingId && !selectedBooking) {
fetchBookingDetails(); 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<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => { whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
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 { function isDateUnavailable(date: DateValue): boolean {
const d = date as CalendarDate; const d = date as CalendarDate;
const jsDate = d.toDate(getLocalTimeZone()); const jsDate = d.toDate(getLocalTimeZone());
const now = new Date(); const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 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 { function formatTime(time: string): string {
@@ -157,18 +205,111 @@
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; 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[] = []; const slots: string[] = [];
for (let h = 8; h <= 18; h++) { const now = new SvelteDate();
for (let m = 0; m < 60; m += 30) { const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
if (h === 18 && m > 0) break; const isToday = date.compare(todayCal) === 0;
slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '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; 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() { async function submitReschedule() {
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return; if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
@@ -456,35 +597,71 @@
Request Reschedule Request Reschedule
</h3> </h3>
<div class="flex items-center justify-center"> {#if loadingRescheduleHours}
<DatePicker <div class="flex items-center justify-center p-6">
date={rescheduleDate} <p class="text-sm text-gray-500">Loading available dates...</p>
placeholder={reschedulePlaceholder} </div>
minValue={minDate} {:else}
maxValue={maxCalendarDate} <div class="flex items-center justify-center">
isDateUnavailable={isDateUnavailable} <DatePicker
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }} date={rescheduleDate}
onPlaceholderChange={(p) => { reschedulePlaceholder = p; }} placeholder={reschedulePlaceholder}
/> minValue={minDate}
</div> maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable}
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }}
onPlaceholderChange={(p) => {
reschedulePlaceholder = p;
if (!rescheduleWorkingHours) fetchRescheduleHours(p);
}}
/>
</div>
{/if}
{#if rescheduleDate} {#if rescheduleDate}
<div class="no-scrollbar mt-2 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"> {#if loadingRescheduleHours}
<div class="grid justify-center gap-2 text-sm text-gray-600"> <div class="flex items-center justify-center border-t p-6">
{rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })} <p class="text-sm text-gray-500">Loading times...</p>
</div> </div>
<div class="grid gap-2"> {:else}
{#each timeSlots as slot (slot)} <div class="no-scrollbar mt-2 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4">
<Button <div class="grid justify-center gap-2 text-sm text-gray-600">
variant="outline" {rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
onclick={() => { rescheduleTime = slot; }} </div>
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot ? 'bg-fuchsia-100' : ''}" {#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen}
> <p class="text-center text-sm text-gray-500">We're closed on this day</p>
{formatTime(slot)} {:else}
</Button> {@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate)}
{/each} {#if grouped.length > 0}
<div class="grid gap-2">
{#each grouped as slot (slot.startTime + slot.endTime)}
{#if slot.type === 'available'}
<Button
variant="outline"
onclick={() => { rescheduleTime = slot.startTime; }}
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot.startTime ? 'bg-fuchsia-100' : ''}"
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{/each}
</div>
{:else}
<p class="text-center text-sm text-gray-500">No available slots</p>
{/if}
{/if}
</div> </div>
</div> {/if}
{/if} {/if}
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
@@ -544,6 +721,8 @@
rescheduleDate = undefined; rescheduleDate = undefined;
rescheduleTime = ''; rescheduleTime = '';
rescheduleNotes = ''; rescheduleNotes = '';
} else if (!rescheduleWorkingHours) {
fetchRescheduleHours(reschedulePlaceholder);
} }
}} }}
> >