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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = 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}
|
||||
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
|
||||
{:else}
|
||||
{@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate)}
|
||||
{@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate, rescheduleLunchProtection())}
|
||||
{#if grouped.length > 0}
|
||||
<div class="grid gap-2">
|
||||
{#each grouped as slot (slot.startTime + slot.endTime)}
|
||||
{#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
||||
{#if slot.type === 'available'}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -652,12 +652,14 @@
|
||||
if (currentUnavailableStart !== null) {
|
||||
const unavailableStartTime = lastAvailableEndTime || currentUnavailableStart;
|
||||
const groupEndTime = calculatePreviousTime(timeStr);
|
||||
if (timeToMinutes(unavailableStartTime) < timeToMinutes(groupEndTime)) {
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: unavailableStartTime,
|
||||
endTime: groupEndTime,
|
||||
isGrouped: true
|
||||
});
|
||||
}
|
||||
currentUnavailableStart = null;
|
||||
}
|
||||
|
||||
@@ -1263,7 +1265,7 @@
|
||||
|
||||
{#if groupedTimeSlots.length > 0}
|
||||
<div class="grid gap-2">
|
||||
{#each groupedTimeSlots as slot (slot.startTime)}
|
||||
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
||||
{#if slot.type === 'available'}
|
||||
{@const protection = lunchProtectionStatus().get(slot.startTime)}
|
||||
{#if protection?.isBlocked}
|
||||
|
||||
Reference in New Issue
Block a user