Fix time picker, WIP date picker

This commit is contained in:
2025-10-15 20:03:43 +01:00
parent 0ed89df995
commit 5e9bad058f
3 changed files with 267 additions and 51 deletions
-1
View File
@@ -85,7 +85,6 @@
let defaultHours = $state<WorkingHourRow[]>([]);
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);
+5 -2
View File
@@ -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);
+255 -41
View File
@@ -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<number>(1);
let selectedServices = $state<any[]>([]);
let selectedDate = $state<CalendarDate | undefined>(new CalendarDate(2025, 6, 12));
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(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<Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
> | null>(null);
let loadingWorkingHours = $state<boolean>(false);
const workingHoursCache = new Map<
string,
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
>();
// Initialize date boundaries
const today = new Date();
const maxDate = new Date();
maxDate.setMonth(today.getMonth() + 6);
// 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<CalendarDate>(minDate);
$effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (!workingHoursCache.has(monthKey)) {
fetchWorkingHoursForMonth(placeholder);
}
});
// 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<WorkingHoursDay> = await response.json();
const hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
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<string, { isOpen: boolean; startTime: string; endTime: string }>
) {
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 [];
function generateTimeSlots(duration: number) {
const slots = [];
const startHour = 9;
const endHour = 17;
const [startHour, startMinute] = dayHours.startTime.split(':').map(Number);
const [endHour, endMinute] = dayHours.endTime.split(':').map(Number);
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')}`;
// 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')}`;
// 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);
}
}
}
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,47 +526,49 @@
</Card.Header>
<Card.Content class="p-0">
<Card.Root class="gap-0 border-0 p-0">
<Card.Content class="relative p-0 md:pr-48">
<Card.Content class="relative p-0 md:pr-56">
<div class="flex items-center justify-center p-6">
<Calendar
type="single"
bind:value={selectedDate}
isDateUnavailable={(date) => 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}
/>
</div>
<div
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-l md:border-t-0"
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-l md:border-t-0"
>
{#if loadingWorkingHours && selectedDate}
<div class="text-center text-sm text-gray-500">Loading available times...</div>
{:else if timeSlots.length > 0}
{@const duration = getTotalDuration()}
<div class="grid gap-2">
{#if timeSlots.length > 0}
{#each timeSlots as time (time)}
{@const timeStatus = isTimeInDuration(time, selectedTime, getTotalDuration())}
{@const endTime = calculateEndTime(time, duration)}
<Button
variant="outline"
onclick={() => (selectedTime = time)}
class={`w-full hover:bg-fuchsia-50 ${
timeStatus === 'start'
? ' text-primary bg-fuchsia-200'
: timeStatus === 'duration'
? ' text-primary bg-fuchsia-100'
: ''
time === selectedTime ? ' bg-fuchsia-200' : ''
}`}
>
{time}
{#if timeStatus === 'duration'}
<span class="ml-1 text-xs opacity-70">(selected)</span>
{/if}
{formatTime(time)} <span class="text-gray-500">- {formatTime(endTime)}</span>
</Button>
{/each}
</div>
{:else if selectedServices.length === 0}
<p class="text-center text-sm text-gray-500">Select services first</p>
{:else if !selectedDate}
<p class="text-center text-sm text-gray-500">Select a date first</p>
{:else}
<p class="text-center text-sm text-gray-500">No available slots</p>
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
</Card.Content>
@@ -370,7 +584,7 @@
month: 'short'
})}
</span>
at <span class="font-medium">{selectedTime}</span>
<br />at <span class="font-medium">{formatTime(selectedTime)}</span>
{:else}
Select a date and time
{/if}
@@ -390,7 +604,7 @@
month: 'short'
})}
</span>
at <span class="font-medium">{selectedTime}</span>
at <span class="font-medium">{formatTime(selectedTime)}</span>
{:else}
Select a date and time
{/if}
@@ -511,7 +725,7 @@
<Button
disabled={!canProceedStep3}
onclick={nextStep}
class="bg-primary text-primary-foreground hover:bg-primary/90"
class="bg-primary text-primary-foreground"
>
Next: Payment
</Button>