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 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<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 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<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 {
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
</h3>
<div class="flex items-center justify-center">
<DatePicker
date={rescheduleDate}
placeholder={reschedulePlaceholder}
minValue={minDate}
maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable}
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }}
onPlaceholderChange={(p) => { reschedulePlaceholder = p; }}
/>
</div>
{#if loadingRescheduleHours}
<div class="flex items-center justify-center p-6">
<p class="text-sm text-gray-500">Loading available dates...</p>
</div>
{:else}
<div class="flex items-center justify-center">
<DatePicker
date={rescheduleDate}
placeholder={reschedulePlaceholder}
minValue={minDate}
maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable}
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }}
onPlaceholderChange={(p) => {
reschedulePlaceholder = p;
if (!rescheduleWorkingHours) fetchRescheduleHours(p);
}}
/>
</div>
{/if}
{#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">
<div class="grid justify-center gap-2 text-sm text-gray-600">
{rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
{#if loadingRescheduleHours}
<div class="flex items-center justify-center border-t p-6">
<p class="text-sm text-gray-500">Loading times...</p>
</div>
<div class="grid gap-2">
{#each timeSlots as slot (slot)}
<Button
variant="outline"
onclick={() => { rescheduleTime = slot; }}
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot ? 'bg-fuchsia-100' : ''}"
>
{formatTime(slot)}
</Button>
{/each}
{:else}
<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">
<div class="grid justify-center gap-2 text-sm text-gray-600">
{rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
</div>
{#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen}
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
{:else}
{@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate)}
{#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>
{/if}
{/if}
<div class="mt-4 space-y-2">
@@ -544,6 +721,8 @@
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
} else if (!rescheduleWorkingHours) {
fetchRescheduleHours(reschedulePlaceholder);
}
}}
>