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:
2026-05-10 14:26:28 +01:00
co-authored by Sisyphus
parent e1c815ed09
commit f3fb44f401
2 changed files with 53 additions and 13 deletions
@@ -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}