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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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<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) 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<string, DayHours> | null,
|
||||||
|
availableHours: Record<string, DayAvailability> | null,
|
||||||
|
duration: number,
|
||||||
|
protection: Map<string, LunchProtectionResult>
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user