Files
Crussell/frontend/src/lib/utils/timeSlots.ts
T
popertotsandSisyphus 08d62080c1 feat(frontend): add out_of_hours booking type and timeSlots utils
Add optional out_of_hours field to Booking interface and TimeSlotGroup. Add debug logging to timeSlot generation utilities for easier development.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-22 12:55:32 +01:00

278 lines
9.0 KiB
TypeScript

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<string, DayHours> | null,
availableHours: Record<string, DayAvailability> | null,
duration: number,
isAdmin: boolean
): Map<string, LunchProtectionResult> {
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<string, DayHours> | null,
availableHours: Record<string, DayAvailability> | null,
duration: number,
protection: Map<string, LunchProtectionResult>
): 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<string, DayHours> | null,
availableHours: Record<string, DayAvailability> | null,
duration: number,
protection: Map<string, LunchProtectionResult>
): 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;
}