From 7ee77d6bffab8552fcc4c472c7646a030c748d2d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 28 May 2026 16:29:41 +0100 Subject: [PATCH] feat: shared time slot utility module for booking and admin components Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/lib/utils/timeSlots.ts | 197 ++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 frontend/src/lib/utils/timeSlots.ts diff --git a/frontend/src/lib/utils/timeSlots.ts b/frontend/src/lib/utils/timeSlots.ts new file mode 100644 index 0000000..52dc216 --- /dev/null +++ b/frontend/src/lib/utils/timeSlots.ts @@ -0,0 +1,197 @@ +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; +} + +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) 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); + } + } + } + } + return slots; +} + +export function generateGroupedTimeSlots( + date: CalendarDate, + workingHours: Record | null, + availableHours: Record | null, + duration: number, + protection: Map +): TimeSlotGroup[] { + if (!workingHours) return []; + const dateStr = date.toString(); + const dayWH = workingHours[dateStr]; + if (!dayWH || !dayWH.isOpen) return []; + + 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); + 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 + }); + } + } + + return grouped; +}