refactor: booking flow components with shared utilities and auto-select

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-28 16:30:07 +01:00
co-authored by Sisyphus
parent 7ee77d6bff
commit efbcfbaaf0
5 changed files with 318 additions and 200 deletions
@@ -14,7 +14,7 @@
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { SvelteMap, SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
// Components // Components
import BookingActions from '$lib/components/booking/BookingActions.svelte'; import BookingActions from '$lib/components/booking/BookingActions.svelte';
@@ -349,7 +349,7 @@
if (!selectedDate) return; if (!selectedDate) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey); delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate); await fetchHoursForMonth(selectedDate);
} }
@@ -400,38 +400,6 @@
} }
} }
// =============== ADD: Lunch Protection ===============
const lunchProtectionStatus = $derived(() => {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map();
}
const dateStr = selectedDate.toString();
const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr];
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
return new Map();
}
// Extract existing bookings from the gap between working hours and available hours
const existingBookings = extractBookedSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
dayAvailableHours.slots
);
// Get lunch protection status for all slots
return getLunchProtectionForSlots(
dayWorkingHours.startTime,
dayWorkingHours.endTime,
existingBookings,
getTotalDuration(),
15, // 15 minute slot intervals
false // User journey - requires 1h minimum
);
});
// =============== Working Hours & Available Hours =============== // =============== Working Hours & Available Hours ===============
let workingHours = $state<Record< let workingHours = $state<Record<
string, string,
@@ -446,22 +414,15 @@
let loadingWorkingHours = $state<boolean>(false); let loadingWorkingHours = $state<boolean>(false);
let loadingAvailableHours = $state<boolean>(false); let loadingAvailableHours = $state<boolean>(false);
const workingHoursCache = new SvelteMap< let workingHoursCache: Record<
string, string,
Record<string, { isOpen: boolean; startTime: string; endTime: string }> Record<string, { isOpen: boolean; startTime: string; endTime: string }>
>(); > = {};
const availableHoursCache = new SvelteMap< let availableHoursCache: Record<
string, string,
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
>(); > = {};
$effect(() => {
return () => {
workingHoursCache.clear();
availableHoursCache.clear();
};
});
// Initialize date boundaries // Initialize date boundaries
const today = new SvelteDate(); const today = new SvelteDate();
@@ -480,25 +441,100 @@
let placeholder = $state<CalendarDate>(minDate); let placeholder = $state<CalendarDate>(minDate);
let userNavigatedCalendar = $state(false); let userNavigatedCalendar = $state(false);
let bookingFlowAutoSelectDone = $state(false);
$effect(() => { $effect(() => {
fetchServices(); fetchServices();
}); });
// Preload 3 months on first render to prevent snap-back during navigation // Track which months are currently being fetched (prevents duplicate requests)
let loadingMonths: Record<string, boolean> = {};
// Preload current + next month on first render; subsequent months fetched individually
let initialLoadDone = $state(false); let initialLoadDone = $state(false);
$effect(() => { $effect(() => {
if (!initialLoadDone) { if (!initialLoadDone) {
fetchHoursRange(placeholder, 3); // Pre-seed cache for current + next month
for (let i = 0; i < 2; i++) {
let mYear = placeholder.year;
let mMonth = placeholder.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; startTime: string; endTime: string }>;
availableHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>;
loadingMonths[key] = true;
}
}
fetchHoursRange(placeholder, 2);
initialLoadDone = true; initialLoadDone = true;
} }
}); });
// Fetch additional months when navigating beyond preloaded range // Safety net: fetch silently when navigating to an uncached month
// (uses skipLoadingFlags=true to prevent layout shift / scroll snap)
$effect(() => { $effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`; const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (initialLoadDone && !workingHoursCache.has(monthKey)) { if (initialLoadDone && !(monthKey in workingHoursCache)) {
fetchHoursForMonth(placeholder); fetchHoursForMonth(placeholder, true);
}
});
// Data-driven auto-selection: auto-select the first available date when data loads
$effect(() => {
if (workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingFlowAutoSelectDone) {
bookingFlowAutoSelectDone = true;
const currentDate = new SvelteDate();
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
);
const daysDifference = Math.floor(
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toISOString().split('T')[0];
if (workingHours[dateStr]?.isOpen) {
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
if (!isDateUnavailable(calDate)) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
}
return;
}
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
} }
}); });
@@ -550,18 +586,27 @@
mYear++; mYear++;
} }
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`; const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
workingHoursCache.set(key, whMap); workingHoursCache[key] = whMap;
availableHoursCache.set(key, ahMap); availableHoursCache[key] = ahMap;
delete loadingMonths[key];
} }
workingHours = whMap; // MERGE instead of replace — preserves data from previously loaded months
availableHours = ahMap; workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
if (!selectedDate) {
setDefaultSelectedDate(whMap);
}
} catch (error) { } catch (error) {
console.error('Failed to fetch hours:', error); console.error('Failed to fetch hours:', error);
// Clean up loadingMonths for the range
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
delete loadingMonths[key];
}
if (!selectedDate) { if (!selectedDate) {
selectedDate = minDate; selectedDate = minDate;
} }
@@ -571,17 +616,25 @@
} }
} }
async function fetchHoursForMonth(date: CalendarDate) { async function fetchHoursForMonth(date: CalendarDate, skipLoadingFlags = false) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`; const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) { // Only use cache if the value is truthy (not a pre-seeded null placeholder)
workingHours = workingHoursCache.get(monthKey)!; if (workingHoursCache[monthKey] && availableHoursCache[monthKey]) {
availableHours = availableHoursCache.get(monthKey)!; // MERGE instead of replace — preserves data from other loaded months
workingHours = { ...workingHours, ...workingHoursCache[monthKey] };
availableHours = { ...availableHours, ...availableHoursCache[monthKey] };
return; return;
} }
loadingWorkingHours = true; // Prevent duplicate concurrent requests for the same month
loadingAvailableHours = true; if (loadingMonths[monthKey]) return;
loadingMonths[monthKey] = true;
if (!skipLoadingFlags) {
loadingWorkingHours = true;
loadingAvailableHours = true;
}
try { try {
const startOfMonth = new CalendarDate(date.year, date.month, 1); const startOfMonth = new CalendarDate(date.year, date.month, 1);
@@ -616,8 +669,8 @@
}; };
}); });
workingHoursCache.set(monthKey, workingHoursMap); workingHoursCache[monthKey] = workingHoursMap;
workingHours = workingHoursMap; workingHours = { ...workingHours, ...workingHoursMap };
// Fetch available hours // Fetch available hours
const availableHoursResponse = await fetch( const availableHoursResponse = await fetch(
@@ -640,75 +693,20 @@
}; };
}); });
availableHoursCache.set(monthKey, availableHoursMap); availableHoursCache[monthKey] = availableHoursMap;
availableHours = availableHoursMap; availableHours = { ...availableHours, ...availableHoursMap };
if (!selectedDate) {
setDefaultSelectedDate(workingHoursMap);
}
} catch (error) { } catch (error) {
console.error('Failed to fetch hours:', error); console.error('Failed to fetch hours:', error);
if (!selectedDate) { if (!selectedDate) {
selectedDate = minDate; selectedDate = minDate;
} }
} finally { } finally {
loadingWorkingHours = false; delete loadingMonths[monthKey];
loadingAvailableHours = false; if (!skipLoadingFlags) {
} loadingWorkingHours = false;
} loadingAvailableHours = false;
function setDefaultSelectedDate(
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
) {
const currentDate = new SvelteDate();
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
maxCalendarDate.day
);
const daysDifference = Math.floor(
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toISOString().split('T')[0];
if (hoursMap[dateStr]?.isOpen) {
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
const duration = getTotalDuration() || 60;
const slots = generateAvailableTimeSlots(duration, calDate);
if (slots.length > 0) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
}
return;
}
} }
} }
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
} }
// =============== Time Slot Generation =============== // =============== Time Slot Generation ===============
@@ -764,12 +762,12 @@
const [startHour, startMinute] = slot.startTime.split(':').map(Number); const [startHour, startMinute] = slot.startTime.split(':').map(Number);
const [endHour, endMinute] = slot.endTime.split(':').map(Number); const [endHour, endMinute] = slot.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute; let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15;
const endTotalMinutes = endHour * 60 + endMinute; const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) { if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes(); const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120; const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes); startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
} }
@@ -791,10 +789,7 @@
function generateGroupedTimeSlots( function generateGroupedTimeSlots(
duration: number, duration: number,
date: CalendarDate | undefined, date: CalendarDate | undefined,
lunchProtection: Map< lunchProtectionMap: Map<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = new Map()
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
> = new Map()
): Array<{ ): Array<{
type: 'available' | 'unavailable'; type: 'available' | 'unavailable';
startTime: string; startTime: string;
@@ -830,7 +825,7 @@
if (isToday) { if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes(); const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120; const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes); startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
} }
@@ -845,7 +840,7 @@
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const isAvailable = const isAvailable =
availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked; availableSlots.includes(timeStr) && !lunchProtectionMap.get(timeStr)?.isBlocked;
if (isAvailable) { if (isAvailable) {
if (currentUnavailableStart !== null) { if (currentUnavailableStart !== null) {
@@ -924,6 +919,11 @@
if (!dayHours) return true; if (!dayHours) return true;
if (!dayHours.isOpen) return true; if (!dayHours.isOpen) return true;
// No available hours data for this date = data not loaded = unavailable
if (!availableHours?.[dateStr]) return true;
// API returned empty slots = no availability at all
if (!availableHours[dateStr].slots || availableHours[dateStr].slots.length === 0) return true;
if (selectedServices.length === 0) { if (selectedServices.length === 0) {
return false; return false;
} }
@@ -932,8 +932,9 @@
const availableSlots = generateAvailableTimeSlots(duration, date); const availableSlots = generateAvailableTimeSlots(duration, date);
if (availableSlots.length === 0) return true; if (availableSlots.length === 0) return true;
const dayAvailableHours = availableHours?.[dateStr]; const dayAvailableHours = availableHours[dateStr];
if (dayAvailableHours?.slots) { // dayAvailableHours.slots is already checked above, but keep this guard for safety
if (dayAvailableHours.slots) {
const existingBookings = extractBookedSlots( const existingBookings = extractBookedSlots(
dayHours.startTime, dayHours.startTime,
dayHours.endTime, dayHours.endTime,
@@ -979,26 +980,55 @@
// Only clear if we're on the date/time selection step // Only clear if we're on the date/time selection step
if (currentStep === 2 && selectedDate) { if (currentStep === 2 && selectedDate) {
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey); delete availableHoursCache[monthKey];
fetchHoursForMonth(selectedDate); fetchHoursForMonth(selectedDate);
} }
// Reset date selection when services change so auto-select can re-run
bookingFlowAutoSelectDone = false;
selectedDate = undefined;
selectedTime = null; selectedTime = null;
} }
// =============== Lunch Protection for Rendering ===============
function getLunchProtectionStatus() {
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
return new Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
>();
}
const dateStr = selectedDate.toString();
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) return new Map();
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
return getLunchProtectionForSlots(
dayWH.startTime,
dayWH.endTime,
existingBookings,
getTotalDuration(),
15,
false
);
}
// Select a time slot with server-side re-validation // Select a time slot with server-side re-validation
async function selectTimeWithValidation(time: string) { async function selectTimeWithValidation(time: string) {
selectedTime = time; selectedTime = time;
await refreshAndValidateSlot(); await refreshAndValidateSlot();
} }
// Re-fetch available hours and check if selectedTime is still available // Re-fetch available hours silently and check if selectedTime is still available
// Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker)
async function refreshAndValidateSlot() { async function refreshAndValidateSlot() {
if (!selectedDate || !selectedTime) return; if (!selectedDate || !selectedTime) return;
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey); delete availableHoursCache[monthKey];
await fetchHoursForMonth(selectedDate); await fetchHoursForMonth(selectedDate, true);
const dateStr = selectedDate.toString(); const dateStr = selectedDate.toString();
const dayAvailable = availableHours?.[dateStr]?.slots; const dayAvailable = availableHours?.[dateStr]?.slots;
@@ -1064,9 +1094,10 @@
// =============== Derived Values =============== // =============== Derived Values ===============
const formattedTotalDuration = $derived(formatDuration(getTotalDuration())); const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
const lunchProtectionMap = $derived(getLunchProtectionStatus());
const groupedTimeSlots = $derived( const groupedTimeSlots = $derived(
currentStep === 2 && selectedServices.length > 0 && selectedDate currentStep === 2 && selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionStatus()) ? generateGroupedTimeSlots(getTotalDuration(), selectedDate, lunchProtectionMap)
: [] : []
); );
const formattedSelectedDate = $derived( const formattedSelectedDate = $derived(
@@ -1425,8 +1456,8 @@
selectedTime = null; selectedTime = null;
}} }}
onPlaceholderChange={(newPlaceholder) => { onPlaceholderChange={(newPlaceholder) => {
userNavigatedCalendar = true;
placeholder = newPlaceholder; placeholder = newPlaceholder;
userNavigatedCalendar = true;
}} }}
/> />
{/if} {/if}
@@ -1446,7 +1477,6 @@
onselect={(time) => { onselect={(time) => {
selectTimeWithValidation(time); selectTimeWithValidation(time);
}} }}
lunchProtectionStatus={lunchProtectionStatus()}
/> />
{/if} {/if}
</Card.Content> </Card.Content>
@@ -24,16 +24,28 @@
<div class="flex items-center justify-center p-6"> <div class="flex items-center justify-center p-6">
<Calendar <Calendar
type="single" type="single"
bind:value={date} value={date}
bind:placeholder placeholder={placeholder}
{isDateUnavailable} // preventDeselect tells bits-ui to never deselect an already-selected date
// (which would fire onValueChange(undefined) and desync the $bindable() chain)
preventDeselect={true}
// Inline wrapper creates a new function reference on every DatePicker render,
// forcing bits-ui Calendar to re-evaluate availability for each date.
// Without this the stable isDateUnavailable reference may cause stale availability
// after workingHours/availableHours are updated (e.g. after month data loading).
isDateUnavailable={(d) => isDateUnavailable(d)}
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0" class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
weekdayFormat="short" weekdayFormat="short"
{minValue} {minValue}
{maxValue} {maxValue}
locale="en-GB" locale="en-GB"
onValueChange={(v: DateValue | undefined) => { onValueChange={(v: DateValue | undefined) => {
if (onchange) { // Guard: bits-ui fires onValueChange(undefined) as an intermediate deselect
// before firing onValueChange(selectedDate). In controlled mode this causes
// the parent to briefly set selectedDate=undefined, which the Calendar then
// receives back, effectively canceling the new selection. Only propagate
// defined values to prevent the first-click-does-nothing issue.
if (onchange && v) {
onchange(v as CalendarDate | undefined); onchange(v as CalendarDate | undefined);
} }
}} }}
@@ -0,0 +1,44 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator';
import type { LunchProtectionResult } from '$lib/lunchProtection';
interface Props {
selectedDate: string;
selectedTime: string;
endTime: string;
duration: number;
protection: LunchProtectionResult | undefined;
}
let { selectedDate, selectedTime, endTime, duration, protection }: Props = $props();
</script>
<Separator class="my-4" />
{#if protection?.showWarning || protection?.isBlocked}
<div class="rounded-lg border {protection?.isBlocked ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50'} p-4">
<div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0 {protection?.isBlocked ? 'text-red-600' : 'text-amber-600'}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<div>
<div class="text-sm font-medium {protection?.isBlocked ? 'text-red-800' : 'text-amber-800'}">
{protection?.isBlocked ? 'Lunch break conflict' : 'Lunch break warning'}
</div>
<div class="mt-1 text-sm {protection?.isBlocked ? 'text-red-700' : 'text-amber-700'}">
{protection?.warningMessage}
</div>
</div>
</div>
</div>
{/if}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning || protection?.isBlocked ? 'mt-3' : ''}">
<div class="text-sm font-medium text-emerald-800">
{#if protection?.isBlocked}Booking conflict{:else if protection?.showWarning}Lunch warning{:else}Time selected{/if}
</div>
<div class="mt-1 text-sm text-emerald-700">
{selectedDate} at {selectedTime}
{' — '}
{endTime}
{' ('}{duration} min)
</div>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { formatTime, type TimeSlotGroup } from '$lib/utils/timeSlots';
import type { LunchProtectionResult } from '$lib/lunchProtection';
interface Props {
slots: TimeSlotGroup[];
selectedTime: string | null;
duration: number;
protection: Map<string, LunchProtectionResult>;
onSelect: (time: string) => void;
}
let { slots, selectedTime, duration, protection, onSelect }: Props = $props();
</script>
{#if slots.length > 0}
<div class="grid gap-2">
{#each slots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
{#if slot.type === 'available'}
{@const p = protection.get(slot.startTime)}
{#if p?.isBlocked}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
title={p.warningMessage || 'Lunch protection'}
>
{formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
</Button>
{:else}
<Button
variant="outline"
onclick={() => onSelect(slot.startTime)}
class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime
? (p?.showWarning ? 'bg-fuchsia-200 border-amber-500' : 'bg-fuchsia-100')
: (p?.showWarning ? 'border-amber-400 bg-amber-100' : '')
}`}
title={p?.warningMessage}
>
{#if p?.showWarning}
<span class="mr-1 text-amber-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</span>
{/if}
{formatTime(slot.startTime)}
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
</Button>
{/if}
{: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 ml-2">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{/each}
</div>
{:else}
<p class="text-center text-sm text-gray-500 py-4">No available slots for this date</p>
{/if}
@@ -7,8 +7,7 @@
groupedTimeSlots = [], groupedTimeSlots = [],
selectedTime = null, selectedTime = null,
formattedDate, formattedDate,
onselect, onselect
lunchProtectionStatus = new Map()
}: { }: {
date: CalendarDate | undefined; date: CalendarDate | undefined;
groupedTimeSlots?: Array<{ groupedTimeSlots?: Array<{
@@ -20,10 +19,6 @@
selectedTime?: string | null; selectedTime?: string | null;
formattedDate?: string; formattedDate?: string;
onselect?: (time: string) => void; onselect?: (time: string) => void;
lunchProtectionStatus?: Map<
string,
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
>;
} = $props(); } = $props();
function formatTime(time: string): string { function formatTime(time: string): string {
@@ -54,52 +49,19 @@
<div class="grid gap-2"> <div class="grid gap-2">
{#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)} {#each groupedTimeSlots as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
{#if slot.type === 'available'} {#if slot.type === 'available'}
{@const protection = lunchProtectionStatus.get(slot.startTime)} <!-- Available slot -->
{#if protection?.isBlocked} <Button
<!-- Blocked by lunch protection --> variant="outline"
<Button onclick={() => {
variant="outline" if (onselect) {
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100" onselect(slot.startTime);
disabled }
title={protection.warningMessage || 'Lunch protection'} }}
> class={`w-full hover:bg-fuchsia-50 ${slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''}`}
{formatTime(slot.startTime)} >
<span class="text-gray-500">- {formatTime(slot.endTime)}</span> {formatTime(slot.startTime)}
</Button> <span class="text-gray-500">- {formatTime(slot.endTime)}</span>
{:else} </Button>
<!-- Available slot (possibly with warning for admin) -->
<Button
variant="outline"
onclick={() => {
if (onselect) {
onselect(slot.startTime);
}
}}
class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
} ${protection?.showWarning ? 'border-amber-400 bg-amber-50' : ''}`}
title={protection?.warningMessage}
>
{#if protection?.showWarning}
<span class="mr-1 text-amber-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
</span>
{/if}
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{:else} {:else}
<!-- Unavailable slot (already booked) --> <!-- Unavailable slot (already booked) -->
<Button <Button