import { SvelteDate } from 'svelte/reactivity'; import { CalendarDate } from '@internationalized/date'; import { extractBookedSlots, getLunchProtectionForSlots, type LunchProtectionResult } from '$lib/lunchProtection'; export interface TimeSlotGroup { type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean; outOfHours?: boolean; } export interface DayHours { isOpen: boolean; startTime: string; endTime: string; } export interface DayAvailability { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }>; } export function formatTime(time: string): string { const parts = time.split(':').map(Number); const hours = parts[0]; const minutes = parts.length > 1 ? parts[1] : 0; if (hours === 12 && minutes === 0) return 'Noon'; 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}`; } export function calculateEndTime(startTime: string, durationMinutes: number): string { const [hours, minutes] = startTime.split(':').map(Number); const totalMinutes = hours * 60 + minutes + durationMinutes; return `${String(Math.floor(totalMinutes / 60)).padStart(2, '0')}:${String(totalMinutes % 60).padStart(2, '0')}`; } export function timeToMinutes(time: string): number { const [hours, minutes] = time.split(':').map(Number); return hours * 60 + minutes; } export function calculatePreviousTime(time: string): string { const [hours, minutes] = time.split(':').map(Number); let totalMinutes = hours * 60 + minutes - 15; return `${String(Math.floor(totalMinutes / 60)).padStart(2, '0')}:${String(totalMinutes % 60).padStart(2, '0')}`; } export function normalizeTime(time: string): string { const parts = time.split(':'); return `${parts[0]}:${parts[1]}`; } export function getDayWithOrdinal(date: CalendarDate): string { const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString( 'en-GB', { month: 'long' } ); const day = date.day; if (day > 3 && day < 21) return monthName + ' ' + day + 'th'; switch (day % 10) { case 1: return monthName + ' ' + day + 'st'; case 2: return monthName + ' ' + day + 'nd'; case 3: return monthName + ' ' + day + 'rd'; default: return monthName + ' ' + day + 'th'; } } export function buildLunchProtection( date: CalendarDate, workingHours: Record | null, availableHours: Record | null, duration: number, isAdmin: boolean ): Map { if (!workingHours || !availableHours) return new Map(); const dateStr = date.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, duration, 15, isAdmin ); } export function generateAvailableTimeSlots( date: CalendarDate, workingHours: Record | null, availableHours: Record | null, duration: number, protection: Map ): string[] { if (!workingHours || !availableHours) return []; const dateStr = date.toString(); const dayWH = workingHours[dateStr]; const dayAH = availableHours[dateStr]; if (!dayWH?.isOpen || !dayAH?.slots) { console.log('[DEBUG] generateAvailableTimeSlots - early return', { dateStr, hasDayWH: !!dayWH, dayWH_isOpen: dayWH?.isOpen, hasDayAH: !!dayAH, dayAH_slots: dayAH?.slots?.length }); return []; } const slots: string[] = []; const now = new SvelteDate(); const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); const isToday = date.compare(todayCal) === 0; for (const slot of dayAH.slots) { const [startHour, startMinute] = slot.startTime.split(':').map(Number); const [endHour, endMinute] = slot.endTime.split(':').map(Number); let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15; const endTotalMinutes = endHour * 60 + endMinute; if (isToday) { const currentMinutes = now.getHours() * 60 + now.getMinutes(); let minimumStart = Math.ceil((currentMinutes + 15) / 15) * 15; startTotalMinutes = Math.max(startTotalMinutes, minimumStart); } for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) { if (minutes + duration <= endTotalMinutes) { const timeStr = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; const p = protection.get(timeStr); if (!p || !p.isBlocked) { slots.push(timeStr); } } } } console.log('[DEBUG] generateAvailableTimeSlots result', { dateStr, duration, dayWH_startTime: dayWH.startTime, dayWH_endTime: dayWH.endTime, dayAH_slots_raw: dayAH.slots, isToday, generatedSlotsCount: slots.length, generatedSlots: slots.slice(0, 20) + (slots.length > 20 ? `... (${slots.length} total)` : '') }); return slots; } export function generateGroupedTimeSlots( date: CalendarDate, workingHours: Record | null, availableHours: Record | null, duration: number, protection: Map ): TimeSlotGroup[] { if (!workingHours) { console.log('[DEBUG] generateGroupedTimeSlots - no workingHours'); return []; } const dateStr = date.toString(); const dayWH = workingHours[dateStr]; if (!dayWH || !dayWH.isOpen) { console.log('[DEBUG] generateGroupedTimeSlots - day not open', { dateStr, dayWH }); return []; } console.log('[DEBUG] generateGroupedTimeSlots - inputs', { dateStr, duration, startTime: dayWH.startTime, endTime: dayWH.endTime, isOpen: dayWH.isOpen, source: (dayWH as any).source }); const grouped: TimeSlotGroup[] = []; const [startHour, startMinute] = dayWH.startTime.split(':').map(Number); const [endHour, endMinute] = dayWH.endTime.split(':').map(Number); let startTotalMinutes = startHour * 60 + startMinute; const endTotalMinutes = endHour * 60 + endMinute; const now = new SvelteDate(); const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); if (date.compare(todayCal) === 0) { const currentMinutes = now.getHours() * 60 + now.getMinutes(); startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15); } const availableSlots = generateAvailableTimeSlots( date, workingHours, availableHours, duration, protection ); console.log('[DEBUG] generateGroupedTimeSlots - availableSlots', { availableSlotsCount: availableSlots.length, availableSlots: availableSlots.slice(0, 30) + (availableSlots.length > 30 ? `... (${availableSlots.length} total)` : ''), dayRange: `${dayWH.startTime}-${dayWH.endTime}`, startTotalMinutes, endTotalMinutes }); let currentUnavailableStart: string | null = null; let lastAvailableEndTime: string | null = null; for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) { const timeStr = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; const p = protection.get(timeStr); const isAvailable = availableSlots.includes(timeStr) && !p?.isBlocked; if (isAvailable) { if (currentUnavailableStart !== null) { const unavailStart = lastAvailableEndTime || currentUnavailableStart; const groupEnd = calculatePreviousTime(timeStr); if (timeToMinutes(unavailStart) < timeToMinutes(groupEnd)) { grouped.push({ type: 'unavailable', startTime: unavailStart, endTime: groupEnd, isGrouped: true }); } currentUnavailableStart = null; } const slotEnd = calculateEndTime(timeStr, duration); lastAvailableEndTime = slotEnd; grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd }); if (timeToMinutes(slotEnd) >= endTotalMinutes) break; } else { if (currentUnavailableStart === null) { currentUnavailableStart = timeStr; } } } if (currentUnavailableStart !== null) { const lastAvail = grouped.filter((s) => s.type === 'available').pop(); const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0; const unavailStartMin = timeToMinutes(currentUnavailableStart); if (unavailStartMin < endTotalMinutes && lastAvailEnd < endTotalMinutes) { grouped.push({ type: 'unavailable', startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart, endTime: normalizeTime(dayWH.endTime), isGrouped: true }); } } console.log('[DEBUG] generateGroupedTimeSlots - final result', { availableCount: grouped.filter(s => s.type === 'available').length, unavailableCount: grouped.filter(s => s.type === 'unavailable').length, availableTimes: grouped.filter(s => s.type === 'available').map(s => s.startTime + '-' + s.endTime).slice(0, 20), unavailableRanges: grouped.filter(s => s.type === 'unavailable').map(s => s.startTime + '-' + s.endTime) }); return grouped; }