time picker changes

This commit is contained in:
2025-10-17 00:08:40 +01:00
parent 8c7d9d159f
commit 35f5dd2563
2 changed files with 363 additions and 126 deletions
+37 -18
View File
@@ -413,17 +413,30 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
http.Error(w, "start and end query params required", http.StatusBadRequest)
return
}
start, err := time.Parse("2006-01-02", startStr)
// Load UK timezone
ukLocation, err := time.LoadLocation("Europe/London")
if err != nil {
http.Error(w, "failed to load timezone", http.StatusInternalServerError)
return
}
// Parse dates in UK timezone
start, err := time.ParseInLocation("2006-01-02", startStr, ukLocation)
if err != nil {
http.Error(w, "invalid start date", http.StatusBadRequest)
return
}
end, err := time.Parse("2006-01-02", endStr)
end, err := time.ParseInLocation("2006-01-02", endStr, ukLocation)
if err != nil {
http.Error(w, "invalid end date", http.StatusBadRequest)
return
}
// Set to start of day (00:00:00) and end of day (23:59:59)
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation)
// Load default hours
defaultMap := map[int]DefaultHours{}
defRows, _ := db.DB.Query(r.Context(), `
@@ -480,30 +493,36 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
}
// Load bookings with their total duration (sum of all services)
bookingRows, _ := db.DB.Query(r.Context(), `
SELECT
b.start_time,
COALESCE(SUM(s.duration_minutes), 0) as total_duration
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE b.start_time >= $1
AND b.start_time < $2 + INTERVAL '1 day'
AND b.status IN ('confirmed', 'pending')
GROUP BY b.id, b.start_time
ORDER BY b.start_time
`, start, end)
bookingRows, err := db.DB.Query(r.Context(), `
SELECT
b.start_time,
COALESCE(SUM(s.duration_minutes), 0) as total_duration
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE b.start_time >= $1
AND b.start_time <= $2
GROUP BY b.id, b.start_time
ORDER BY b.start_time
`, start, end)
if err != nil {
http.Error(w, "failed to query bookings", http.StatusInternalServerError)
return
}
bookings := map[string][]TimeSlot{} // date -> booked slots
for bookingRows.Next() {
var startTime time.Time
var durationMinutes int
if err := bookingRows.Scan(&startTime, &durationMinutes); err == nil {
dateStr := startTime.Format("2006-01-02")
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
// Convert to UK timezone for date formatting
startTimeUK := startTime.In(ukLocation)
dateStr := startTimeUK.Format("2006-01-02")
endTime := startTimeUK.Add(time.Duration(durationMinutes) * time.Minute)
bookings[dateStr] = append(bookings[dateStr], TimeSlot{
StartTime: startTime.Format("15:04"),
StartTime: startTimeUK.Format("15:04"),
EndTime: endTime.Format("15:04"),
})
}
+326 -108
View File
@@ -81,17 +81,30 @@
}
];
// Working hours state
// Working hours and available hours state
let workingHours = $state<Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
> | null>(null);
let availableHours = $state<Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> | null>(null);
let loadingWorkingHours = $state<boolean>(false);
let loadingAvailableHours = $state<boolean>(false);
const workingHoursCache = new Map<
string,
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
>();
const availableHoursCache = new Map<
string,
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
>();
// Initialize date boundaries
const today = new Date();
const tomorrow = new Date(today);
@@ -99,7 +112,7 @@
const maxDate = new Date();
maxDate.setMonth(today.getMonth() + 6);
// Create CalendarDate objects directly without toCalendar conversion
// Create CalendarDate objects
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const tomorrowDate = new CalendarDate(
tomorrow.getFullYear(),
@@ -111,26 +124,32 @@
maxDate.getMonth() + 1,
maxDate.getDate()
);
let placeholder = $state<CalendarDate>(tomorrowDate);
$effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (!workingHoursCache.has(monthKey)) {
fetchWorkingHoursForMonth(placeholder);
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
fetchHoursForMonth(placeholder);
}
});
// Fetch working hours for a given month
async function fetchWorkingHoursForMonth(date: CalendarDate) {
// Fetch both working hours and available hours for a given month
async function fetchHoursForMonth(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
if (workingHoursCache.has(monthKey)) {
// Use a timeout to ensure state updates properly
// Use cached data if available
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
setTimeout(() => {
workingHours = workingHoursCache.get(monthKey)!;
availableHours = availableHoursCache.get(monthKey)!;
}, 0);
return;
}
loadingWorkingHours = true;
loadingAvailableHours = true;
try {
// Calculate start and end of month
const startOfMonth = new CalendarDate(date.year, date.month, 1);
@@ -143,9 +162,12 @@
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}`);
// Fetch working hours
const workingHoursResponse = await fetch(
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`
);
if (!workingHoursResponse.ok) {
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
}
type WorkingHoursDay = {
@@ -157,43 +179,78 @@
source: string;
};
const data: Array<WorkingHoursDay> = await response.json();
const workingHoursData: Array<WorkingHoursDay> = await workingHoursResponse.json();
const workingHoursMap: Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
> = {};
const hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
data.forEach((day) => {
hoursMap[day.date] = {
workingHoursData.forEach((day) => {
workingHoursMap[day.date] = {
isOpen: day.isOpen,
startTime: day.startTime,
endTime: day.endTime
};
});
workingHoursCache.set(monthKey, hoursMap);
workingHours = hoursMap;
workingHoursCache.set(monthKey, workingHoursMap);
workingHours = workingHoursMap;
// Fetch available hours
const availableHoursResponse = await fetch(
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`
);
if (!availableHoursResponse.ok) {
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
}
type AvailableHoursDay = {
date: string;
weekday: number;
isOpen: boolean;
slots: Array<{ startTime: string; endTime: string }>;
source: string;
};
const availableHoursData: Array<AvailableHoursDay> = await availableHoursResponse.json();
const availableHoursMap: Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
> = {};
availableHoursData.forEach((day) => {
availableHoursMap[day.date] = {
isOpen: day.isOpen,
slots: day.slots
};
});
availableHoursCache.set(monthKey, availableHoursMap);
availableHours = availableHoursMap;
// Set default selected date if not set
if (!selectedDate) {
setDefaultSelectedDate(hoursMap);
setDefaultSelectedDate(workingHoursMap);
}
} catch (error) {
console.error('Failed to fetch working hours:', error);
console.error('Failed to fetch hours:', error);
// Fallback to current date if API fails
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
// Set default selected date to next available working day (starting from tomorrow)
// 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 (starting from tomorrow, i=1)
for (let i = 1; i < 30; i++) {
nextDate = new Date(currentDate);
nextDate.setDate(currentDate.getDate() + i);
@@ -209,7 +266,6 @@
}
}
// Fallback to tomorrow if no working day found
if (!selectedDate) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
@@ -223,36 +279,23 @@
/**
* 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 */
/** Format time from HH:MM:SS to human-readable 12-hour format */
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) {
@@ -265,19 +308,10 @@
}
// Initialize with current month
fetchWorkingHoursForMonth(tomorrowDate);
fetchHoursForMonth(tomorrowDate);
// 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
// Check if date is unavailable
function isDateUnavailable(date: DateValue): boolean {
// Only CalendarDate has compare method
if (!(date instanceof CalendarDate)) {
return true;
}
@@ -302,70 +336,182 @@
// Check if there are any available time slots for the selected services
if (selectedServices.length > 0) {
const duration = getTotalDuration();
const slots = generateTimeSlots(duration, date);
const availableSlots = generateAvailableTimeSlots(duration, date);
// If no slots available for the required duration, mark as unavailable
if (slots.length === 0) return true;
if (availableSlots.length === 0) return true;
}
return false;
}
function generateTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
// Use the new grouped time slots generation for visual display
const groupedTimeSlots = $derived(
selectedServices.length > 0 && selectedDate
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
: []
);
// NEW: Generate grouped time slots (available as individual buttons, unavailable as grouped blocks)
function generateGroupedTimeSlots(
duration: number,
date: CalendarDate | undefined
): Array<{
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
}> {
if (!date || !workingHours) {
return [];
}
const dateStr = date.toString();
const dayHours = workingHours[dateStr];
const dayWorkingHours = workingHours[dateStr];
if (!dayHours || !dayHours.isOpen) {
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
return [];
}
const slots: string[] = [];
const [startHour, startMinute] = dayHours.startTime.split(':').map(Number);
const [endHour, endMinute] = dayHours.endTime.split(':').map(Number);
const groupedSlots: Array<{
type: 'available' | 'unavailable';
startTime: string;
endTime: string;
isGrouped?: boolean;
}> = [];
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const now = new Date();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(today) === 0;
// Apply 2-hour buffer for today's appointments
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120; // Current time + 2 hours
const minimumStartMinutes = currentMinutes + 120;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
// Get available time slots that fit our duration
const availableSlots = generateAvailableTimeSlots(duration, date);
let currentUnavailableStart: string | null = null;
// Generate all 15-minute increments within working hours
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const slotEndMinutes = minutes + duration;
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);
// Check if this time slot is available (fits duration and within available hours)
const isAvailable = availableSlots.includes(timeStr);
if (isAvailable) {
// If we were building an unavailable group, push it first
if (currentUnavailableStart !== null) {
const groupEndTime = calculatePreviousTime(timeStr); // End before this available slot
groupedSlots.push({
type: 'unavailable',
startTime: currentUnavailableStart,
endTime: groupEndTime,
isGrouped: true
});
currentUnavailableStart = null;
}
// Add available slot
const slotEndTime = calculateEndTime(timeStr, duration);
groupedSlots.push({
type: 'available',
startTime: timeStr,
endTime: slotEndTime
});
} else {
// Start or continue unavailable group
if (currentUnavailableStart === null) {
currentUnavailableStart = timeStr;
}
// Continue the group - we'll close it when we hit an available slot or end
}
}
// Close any remaining unavailable group at the end of the day
if (currentUnavailableStart !== null) {
groupedSlots.push({
type: 'unavailable',
startTime: currentUnavailableStart,
endTime: dayWorkingHours.endTime,
isGrouped: true
});
}
return groupedSlots;
}
// Helper: Calculate the previous 15-minute time slot
function calculatePreviousTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
let totalMinutes = hours * 60 + minutes;
totalMinutes -= 15; // Go back 15 minutes
const prevHours = Math.floor(totalMinutes / 60);
const prevMinutes = totalMinutes % 60;
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
}
// NEW: Generate available time slots within available segments
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
if (!date || !workingHours || !availableHours) {
return [];
}
const dateStr = date.toString();
const dayWorkingHours = workingHours[dateStr];
const dayAvailableHours = availableHours[dateStr];
if (!dayWorkingHours || !dayWorkingHours.isOpen || !dayAvailableHours) {
return [];
}
const slots: string[] = [];
const now = new Date();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(today) === 0;
for (const slot of dayAvailableHours.slots) {
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
// Apply 2-hour buffer for today's appointments
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
// Generate 15-minute increments within this available slot
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const slotEndMinutes = minutes + duration;
// Check if the full duration fits within the available slot
if (slotEndMinutes <= endTotalMinutes) {
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 and date
const timeSlots = $derived(
selectedServices.length > 0 && selectedDate
? generateTimeSlots(getTotalDuration(), selectedDate)
: []
);
function getTotalDuration() {
return selectedServices.reduce((total, service) => total + service.duration, 0);
}
@@ -376,40 +522,90 @@
function toggleService(service: any) {
const index = selectedServices.findIndex((s) => s.id === service.id);
if (index >= 0) {
const wasSelected = index >= 0;
if (wasSelected) {
selectedServices = selectedServices.filter((s) => s.id !== service.id);
} else {
selectedServices = [...selectedServices, service];
}
// Reset time selection when services change
// Clear selected date and time when services change, as they may no longer be valid
selectedTime = null;
selectedDate = undefined;
// Re-fetch available hours when services change and we're on step 2
if (currentStep === 2) {
// Clear the cache to force refetch with new duration
availableHoursCache.clear();
if (selectedDate) {
fetchHoursForMonth(selectedDate);
}
}
}
function isServiceSelected(service: any) {
return selectedServices.some((s) => s.id === service.id);
}
function isTimeInDuration(
time: string,
selectedTime: string | null,
totalDuration: number
): 'start' | 'duration' | 'none' {
if (!selectedTime) return 'none';
if (time === selectedTime) return 'start';
const allTimeSlots = $derived(
selectedServices.length > 0 && selectedDate
? generateAllTimeSlots(getTotalDuration(), selectedDate)
: []
);
const [selectedHour, selectedMin] = selectedTime.split(':').map(Number);
const [currentHour, currentMin] = time.split(':').map(Number);
const selectedMinutes = selectedHour * 60 + selectedMin;
const currentMinutes = currentHour * 60 + currentMin;
const endMinutes = selectedMinutes + totalDuration;
if (currentMinutes > selectedMinutes && currentMinutes < endMinutes) {
return 'duration';
function generateAllTimeSlots(
duration: number,
date: CalendarDate | undefined
): Array<{ time: string; type: 'available' | 'unavailable' }> {
if (!date || !workingHours) {
return [];
}
return 'none';
const dateStr = date.toString();
const dayWorkingHours = workingHours[dateStr];
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
return [];
}
const allSlots: Array<{ time: string; type: 'available' | 'unavailable' }> = [];
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const now = new Date();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(today) === 0;
// Apply 2-hour buffer for today's appointments
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const minimumStartMinutes = currentMinutes + 120;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
// Get available time slots that fit our duration
const availableSlots = generateAvailableTimeSlots(duration, date);
// Generate all 15-minute increments within working hours
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
// Check if this time slot is available (fits duration and within available hours)
const isAvailable = availableSlots.includes(timeStr);
allSlots.push({
time: timeStr,
type: isAvailable ? 'available' : 'unavailable'
});
}
return allSlots;
}
function nextStep() {
@@ -427,6 +623,13 @@
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 50);
// Re-fetch available hours when returning to step 2
if (currentStep === 2 && selectedDate) {
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
availableHoursCache.delete(monthKey); // Force refetch
fetchHoursForMonth(selectedDate);
}
}
}
@@ -586,22 +789,37 @@
<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-56 md:border-l md:border-t-0"
>
{#if loadingWorkingHours && selectedDate}
{#if (loadingWorkingHours || loadingAvailableHours) && selectedDate}
<div class="text-center text-sm text-gray-500">Loading available times...</div>
{:else if timeSlots.length > 0}
{@const duration = getTotalDuration()}
{:else if groupedTimeSlots.length > 0}
<!-- Grouped Time Slots Grid - ORIGINAL STYLING BUT WITH GROUPED UNAVAILABLE SLOTS -->
<div class="grid gap-2">
{#each timeSlots as time (time)}
{@const endTime = calculateEndTime(time, duration)}
<Button
variant="outline"
onclick={() => (selectedTime = time)}
class={`w-full hover:bg-fuchsia-50 ${
time === selectedTime ? ' bg-fuchsia-200' : ''
}`}
>
{formatTime(time)} <span class="text-gray-500">- {formatTime(endTime)}</span>
</Button>
{#each groupedTimeSlots as slot (slot.startTime)}
{#if slot.type === 'available'}
<!-- Available slot - individual button -->
<Button
variant="outline"
onclick={() => {
selectedTime = slot.startTime;
}}
class={`w-full hover:bg-fuchsia-50 ${
slot.startTime === selectedTime ? 'bg-fuchsia-200' : ''
}`}
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<!-- Unavailable slot - grouped block -->
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{/each}
</div>
{:else if selectedServices.length === 0}