From 5e9bad058f1787db24ee03d432f0a194d797fad8 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Wed, 15 Oct 2025 20:03:43 +0100 Subject: [PATCH] Fix time picker, WIP date picker --- frontend/src/routes/admin/+page.svelte | 1 - frontend/src/routes/api/[...path]/+server.ts | 7 +- frontend/src/routes/book/+page.svelte | 310 ++++++++++++++++--- 3 files changed, 267 insertions(+), 51 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 208c2ee..3da5f35 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -85,7 +85,6 @@ let defaultHours = $state([]); let defaultHoursIsLoading = $state(true); - /** Format time from HH:MM:SS to 12-hour format */ /** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */ function formatTime(time: string): string { const [hours, minutes] = time.split(':').map(Number); diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index bb38f48..703c085 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -1,4 +1,3 @@ -// src/routes/api/[...path]/+server.ts import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -6,7 +5,11 @@ const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080'; async function proxyRequest(request: Request, path: string) { - const url = `${BACKEND_URL}/api/${path}`; + const incomingUrl = new URL(request.url); + + const queryString = incomingUrl.search; + + const url = `${BACKEND_URL}/api/${path}${queryString}`; try { const headers = new Headers(request.headers); diff --git a/frontend/src/routes/book/+page.svelte b/frontend/src/routes/book/+page.svelte index 8f40984..3f425c9 100644 --- a/frontend/src/routes/book/+page.svelte +++ b/frontend/src/routes/book/+page.svelte @@ -6,12 +6,12 @@ import { Textarea } from '$lib/components/ui/textarea/index.js'; import { Separator } from '$lib/components/ui/separator/index.js'; import Calendar from '$lib/components/ui/calendar/calendar.svelte'; - import { CalendarDate, getLocalTimeZone } from '@internationalized/date'; + import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; // Booking state let currentStep = $state(1); let selectedServices = $state([]); - let selectedDate = $state(new CalendarDate(2025, 6, 12)); + let selectedDate = $state(undefined); let selectedTime = $state(null); let customerInfo = $state({ firstName: '', @@ -81,33 +81,246 @@ } ]; - // Mock unavailable dates - const bookedDates = Array.from({ length: 5 }, (_, i) => new CalendarDate(2025, 6, 17 + i)); + // Working hours state + let workingHours = $state | null>(null); + let loadingWorkingHours = $state(false); + const workingHoursCache = new Map< + string, + Record + >(); - function generateTimeSlots(duration: number) { - const slots = []; - const startHour = 9; - const endHour = 17; + // Initialize date boundaries + const today = new Date(); + const maxDate = new Date(); + maxDate.setMonth(today.getMonth() + 6); - for (let hour = startHour; hour < endHour; hour++) { - for (let minute = 0; minute < 60; minute += 15) { - // Changed to 15-minute intervals - const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + // Create CalendarDate objects directly without toCalendar conversion + const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate()); + const maxCalendarDate = new CalendarDate( + maxDate.getFullYear(), + maxDate.getMonth() + 1, + maxDate.getDate() + ); + let placeholder = $state(minDate); + $effect(() => { + const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`; + if (!workingHoursCache.has(monthKey)) { + fetchWorkingHoursForMonth(placeholder); + } + }); - // Check if there's enough time before closing - const endTime = new Date(); - endTime.setHours(hour, minute + duration, 0, 0); - if (endTime.getHours() <= endHour) { - slots.push(timeStr); - } + // Fetch working hours for a given month + async function fetchWorkingHoursForMonth(date: CalendarDate) { + const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`; + if (workingHoursCache.has(monthKey)) { + workingHours = workingHoursCache.get(monthKey)!; + return; + } + + loadingWorkingHours = true; + try { + // Calculate start and end of month + 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 response = await fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + type WorkingHoursDay = { + date: string; + weekday: number; + startTime: string; + endTime: string; + isOpen: boolean; + source: string; + }; + + const data: Array = await response.json(); + + const hoursMap: Record = {}; + data.forEach((day) => { + hoursMap[day.date] = { + isOpen: day.isOpen, + startTime: day.startTime, + endTime: day.endTime + }; + }); + + workingHoursCache.set(monthKey, hoursMap); + workingHours = hoursMap; + + // Set default selected date if not set + if (!selectedDate) { + setDefaultSelectedDate(hoursMap); + } + } catch (error) { + console.error('Failed to fetch working hours:', error); + // Fallback to current date if API fails + if (!selectedDate) { + selectedDate = minDate; + } + } finally { + loadingWorkingHours = false; + } + } + + // Set default selected date to next available working day + function setDefaultSelectedDate( + hoursMap: Record + ) { + const currentDate = new Date(); + let nextDate = new Date(currentDate); + + // Check next 30 days for available working day + for (let i = 0; i < 30; i++) { + nextDate.setDate(currentDate.getDate() + i); + const dateStr = nextDate.toISOString().split('T')[0]; + + if (hoursMap[dateStr]?.isOpen) { + selectedDate = new CalendarDate( + nextDate.getFullYear(), + nextDate.getMonth() + 1, + nextDate.getDate() + ); + break; } } + + // Fallback to today if no working day found + if (!selectedDate) { + selectedDate = minDate; + } + } + + /** + * Calculates the end time based on a 24-hour start time and a duration in minutes. + * @param startTime - Time string in "HH:MM:SS" format (e.g., "09:00:00"). + * @param durationMinutes - The total duration of the service in minutes. + * @returns Time string in "HH:MM" 24-hour format (e.g., "10:30"). + */ + function calculateEndTime(startTime: string, durationMinutes: number): string { + const [hours, minutes] = startTime.split(':').map(Number); + + // Create a fixed date object to manipulate the time + const date = new Date(); + // Set time on the fixed date + date.setHours(hours, minutes, 0, 0); + + // Add duration in minutes + date.setMinutes(date.getMinutes() + durationMinutes); + + // Format back to HH:MM (24-hour format) + const endHours = date.getHours().toString().padStart(2, '0'); + const endMinutes = date.getMinutes().toString().padStart(2, '0'); + + return `${endHours}:${endMinutes}`; + } + + /** Format time from HH:MM:SS (or HH:MM) to human-readable 12-hour format, with "Noon" for 12:00 PM */ + function formatTime(time: string): string { + // Handle cases where seconds might be present (HH:MM:SS) or not (HH:MM) + const parts = time.split(':').map(Number); + const hours = parts[0]; + const minutes = parts.length > 1 ? parts[1] : 0; + + // Special case for 12:00 + if (hours === 12 && minutes === 0) { + return 'Noon'; + } else if (hours === 0 && minutes === 0) { + return 'Midnight'; + } + + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; + } + + // Initialize with current month + fetchWorkingHoursForMonth(minDate); + + // Handle calendar month change + function handleMonthChange(newMonth: CalendarDate) { + // Only fetch if within 6 months range + if (newMonth.compare(maxCalendarDate) <= 0) { + fetchWorkingHoursForMonth(newMonth); + } + } + + // Check if date is unavailable - updated to handle DateValue type + function isDateUnavailable(date: DateValue): boolean { + // Only CalendarDate has compare method + if (!(date instanceof CalendarDate)) { + return true; + } + + // Check if date is outside allowed range + if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) { + return true; + } + + // Check if we have working hours data + if (!workingHours) return false; + + const dateStr = date.toString(); + const dayHours = workingHours[dateStr]; + + // If no data for this date, assume unavailable + if (!dayHours) return true; + + return !dayHours.isOpen; + } + + function generateTimeSlots(duration: number, date: CalendarDate | undefined) { + if (!date || !workingHours) return []; + + const dateStr = date.toString(); + const dayHours = workingHours[dateStr]; + + // If no working hours data or closed, return empty + if (!dayHours || !dayHours.isOpen) return []; + + const slots = []; + const [startHour, startMinute] = dayHours.startTime.split(':').map(Number); + const [endHour, endMinute] = dayHours.endTime.split(':').map(Number); + + // Convert to minutes for easier calculation + const startTotalMinutes = startHour * 60 + startMinute; + const endTotalMinutes = endHour * 60 + endMinute; + + // Generate 15-minute intervals + for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) { + const slotEndMinutes = minutes + duration; + + // Skip if slot would end after closing time + if (slotEndMinutes > endTotalMinutes) continue; + + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; + + slots.push(timeStr); + } + return slots; } - // Generate time slots based on selected services (use longest duration) + // Generate time slots based on selected services and date const timeSlots = $derived( - selectedServices.length > 0 ? generateTimeSlots(getTotalDuration()) : [] + selectedServices.length > 0 && selectedDate + ? generateTimeSlots(getTotalDuration(), selectedDate) + : [] ); function getTotalDuration() { @@ -125,6 +338,8 @@ } else { selectedServices = [...selectedServices, service]; } + // Reset time selection when services change + selectedTime = null; } function isServiceSelected(service: any) { @@ -157,7 +372,6 @@ function nextStep() { if (currentStep < 4) { currentStep++; - // Use setTimeout to ensure DOM is updated before scrolling setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 50); @@ -167,7 +381,6 @@ function prevStep() { if (currentStep > 1) { currentStep--; - // Use setTimeout to ensure DOM is updated before scrolling setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 50); @@ -175,7 +388,6 @@ } function handleBooking() { - // This would connect to your backend alert('Booking submitted! (This is just a prototype - no backend connected yet)'); } @@ -238,7 +450,7 @@ class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected( service ) - ? 'bg-fuchsia-100' + ? 'bg-fuchsia-200' : 'border-ring'}" onclick={() => toggleService(service)} > @@ -314,46 +526,48 @@ - +
bookedDates.some((d) => d.compare(date) === 0)} + bind:placeholder + {isDateUnavailable} class="data-unavailable:line-through data-unavailable:opacity-100 bg-transparent p-0 [--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden" weekdayFormat="short" + minValue={minDate} + maxValue={maxCalendarDate} + weekStartsOn={1} />
-
- {#if timeSlots.length > 0} + {#if loadingWorkingHours && selectedDate} +
Loading available times...
+ {:else if timeSlots.length > 0} + {@const duration = getTotalDuration()} +
{#each timeSlots as time (time)} - {@const timeStatus = isTimeInDuration(time, selectedTime, getTotalDuration())} + {@const endTime = calculateEndTime(time, duration)} {/each} - {:else if selectedServices.length === 0} -

Select services first

- {:else} -

No available slots

- {/if} -
+
+ {:else if selectedServices.length === 0} +

Select services first

+ {:else if !selectedDate} +

Select a date first

+ {:else} +

No available slots

+ {/if}
@@ -370,7 +584,7 @@ month: 'short' })} - at {selectedTime} +
at {formatTime(selectedTime)} {:else} Select a date and time {/if} @@ -390,7 +604,7 @@ month: 'short' })} - at {selectedTime} + at {formatTime(selectedTime)} {:else} Select a date and time {/if} @@ -511,7 +725,7 @@