/** * Lunch Protection Utility * * Ensures that a minimum lunch break is preserved in the middle 50% of the working day. * - User journeys: Requires 1h minimum lunch gap (blocks slots that would reduce below 1h) * - Admin journeys: Requires 30min minimum, warns if < 1h remaining */ export interface TimeSlot { startTime: string; // "HH:MM" format endTime: string; // "HH:MM" format } export interface LunchProtectionResult { isBlocked: boolean; showWarning: boolean; warningMessage?: string; } /** * Convert "HH:MM" time string to minutes since midnight */ export function timeToMinutes(time: string): number { const parts = time.split(':'); const hours = parseInt(parts[0], 10); const minutes = parts.length > 1 ? parseInt(parts[1], 10) : 0; return hours * 60 + minutes; } /** * Convert minutes since midnight to "HH:MM" format */ export function minutesToTime(minutes: number): string { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } /** * Calculate the middle 50% window of a working day * Example: 9:00-17:00 -> middle 50% is 11:00-15:00 */ export function calculateMiddleWindow( dayStartTime: string, dayEndTime: string ): { windowStart: number; windowEnd: number } { const startMinutes = timeToMinutes(dayStartTime); const endMinutes = timeToMinutes(dayEndTime); const totalDuration = endMinutes - startMinutes; const quarterDuration = Math.floor(totalDuration / 4); // Middle 50%: from 25% to 75% of the day return { windowStart: startMinutes + quarterDuration, windowEnd: endMinutes - quarterDuration }; } /** * Find all gaps in the middle window after accounting for bookings * Returns an array of gap durations in minutes, sorted descending */ export function findAllLunchGaps( middleWindowStart: number, middleWindowEnd: number, existingBookings: TimeSlot[], proposedBooking?: TimeSlot ): number[] { const allBookings: TimeSlot[] = [...existingBookings]; if (proposedBooking) { allBookings.push(proposedBooking); } const relevantBookings = allBookings .filter((booking) => { const bookingStart = timeToMinutes(booking.startTime); const bookingEnd = timeToMinutes(booking.endTime); return bookingStart < middleWindowEnd && bookingEnd > middleWindowStart; }) .map((booking) => ({ startTime: Math.max(timeToMinutes(booking.startTime), middleWindowStart), endTime: Math.min(timeToMinutes(booking.endTime), middleWindowEnd) })) .sort((a, b) => a.startTime - b.startTime); if (relevantBookings.length === 0) { return [middleWindowEnd - middleWindowStart]; } const gaps: number[] = []; const firstBookingStart = relevantBookings[0].startTime; if (firstBookingStart > middleWindowStart) { gaps.push(firstBookingStart - middleWindowStart); } for (let i = 0; i < relevantBookings.length - 1; i++) { const gapStart = relevantBookings[i].endTime; const gapEnd = relevantBookings[i + 1].startTime; if (gapEnd > gapStart) { gaps.push(gapEnd - gapStart); } } const lastBookingEnd = relevantBookings[relevantBookings.length - 1].endTime; if (lastBookingEnd < middleWindowEnd) { gaps.push(middleWindowEnd - lastBookingEnd); } return gaps.sort((a, b) => b - a); } /** * Find the largest gap in the middle window after accounting for bookings * Returns the duration in minutes of the largest gap */ export function findLargestLunchGap( middleWindowStart: number, middleWindowEnd: number, existingBookings: TimeSlot[], proposedBooking?: TimeSlot ): number { const gaps = findAllLunchGaps( middleWindowStart, middleWindowEnd, existingBookings, proposedBooking ); return gaps.length > 0 ? gaps[0] : 0; } export const LUNCH_MINIMUM_USER = 60; export const LUNCH_MINIMUM_ADMIN = 30; export const LUNCH_WARNING_THRESHOLD = 60; export const LUNCH_MIN_DAY_DURATION = 5 * 60; // 300 minutes — skip lunch for days ≤ 5h /** * Check lunch protection should apply at all for a day of this duration */ export function shouldApplyLunchProtection(dayStartTime: string, dayEndTime: string): boolean { const dayDuration = timeToMinutes(dayEndTime) - timeToMinutes(dayStartTime); return dayDuration > LUNCH_MIN_DAY_DURATION; } /** * Check if a proposed booking slot violates lunch protection */ export function checkLunchProtection( dayStartTime: string, dayEndTime: string, existingBookings: TimeSlot[], proposedSlotStart: string, proposedSlotEnd: string, isAdmin: boolean ): LunchProtectionResult { if (!shouldApplyLunchProtection(dayStartTime, dayEndTime)) { return { isBlocked: false, showWarning: false }; } const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime); const windowDuration = windowEnd - windowStart; const minimumRequired = isAdmin ? LUNCH_MINIMUM_ADMIN : LUNCH_MINIMUM_USER; if (windowDuration < minimumRequired) { return { isBlocked: false, showWarning: false }; } const proposedBooking: TimeSlot = { startTime: proposedSlotStart, endTime: proposedSlotEnd }; const gapWithout = findLargestLunchGap(windowStart, windowEnd, existingBookings); const gapsWith = findAllLunchGaps(windowStart, windowEnd, existingBookings, proposedBooking); const gapWith = gapsWith[0] ?? 0; if (isAdmin) { if (gapWith < LUNCH_MINIMUM_ADMIN && gapWith < gapWithout) { return { isBlocked: true, showWarning: false, warningMessage: `This booking would leave no lunch break (minimum 30 minutes required).` }; } if (gapWith < LUNCH_WARNING_THRESHOLD && gapWith < gapWithout) { const startH = parseInt(proposedSlotStart.split(':')[0], 10); const startM = parseInt(proposedSlotStart.split(':')[1], 10); const endH = parseInt(proposedSlotEnd.split(':')[0], 10); const endM = parseInt(proposedSlotEnd.split(':')[1], 10); const fmt = (h: number, m: number) => { const p = h >= 12 ? 'PM' : 'AM'; return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${p}`; }; const viableGaps = gapsWith.filter((g) => g >= LUNCH_MINIMUM_ADMIN); if (viableGaps.length >= 2 && gapsWith.every((g) => g >= LUNCH_MINIMUM_ADMIN)) { const sorted = [...viableGaps].sort((a, b) => b - a); if (sorted[0] === sorted[1]) { return { isBlocked: false, showWarning: true, warningMessage: `This booking (${fmt(startH, startM)}–${fmt(endH, endM)}) would split your suggested lunch break into two ${sorted[0]} minute blocks.` }; } return { isBlocked: false, showWarning: true, warningMessage: `This booking (${fmt(startH, startM)}–${fmt(endH, endM)}) would split your suggested lunch break into ${sorted[0]} and ${sorted[1]} minute blocks.` }; } return { isBlocked: false, showWarning: true, warningMessage: `This booking (${fmt(startH, startM)}–${fmt(endH, endM)}) would reduce lunch break to ${gapWith} minutes.` }; } return { isBlocked: false, showWarning: false }; } else { if (gapWith < LUNCH_MINIMUM_USER && gapWith < gapWithout) { return { isBlocked: true, showWarning: false, warningMessage: `This booking would leave insufficient lunch break (minimum 1 hour required).` }; } return { isBlocked: false, showWarning: false }; } } /** * Extract booked slots from working hours and available hours * The backend returns available slots (working hours minus bookings) * We reverse-engineer the bookings by finding gaps in available slots */ export function extractBookedSlots( dayStartTime: string, dayEndTime: string, availableSlots: TimeSlot[] ): TimeSlot[] { const dayStart = timeToMinutes(dayStartTime); const dayEnd = timeToMinutes(dayEndTime); const bookedSlots: TimeSlot[] = []; const sortedSlots = [...availableSlots].sort( (a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime) ); let currentPos = dayStart; for (const slot of sortedSlots) { const slotStart = timeToMinutes(slot.startTime); const slotEnd = timeToMinutes(slot.endTime); // If there's a gap before this slot, it's a booking if (slotStart > currentPos) { bookedSlots.push({ startTime: minutesToTime(currentPos), endTime: minutesToTime(slotStart) }); } currentPos = Math.max(currentPos, slotEnd); } // Check for booking at the end of the day if (currentPos < dayEnd) { bookedSlots.push({ startTime: minutesToTime(currentPos), endTime: minutesToTime(dayEnd) }); } return bookedSlots; } /** * Get lunch protection status for all time slots on a given day */ export function getLunchProtectionForSlots( dayStartTime: string, dayEndTime: string, existingBookings: TimeSlot[], slotDuration: number, slotInterval: number, isAdmin: boolean ): Map { const results = new Map(); const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime); const windowDuration = windowEnd - windowStart; const minimumRequired = isAdmin ? LUNCH_MINIMUM_ADMIN : LUNCH_MINIMUM_USER; if (windowDuration < minimumRequired) { return results; } if (!shouldApplyLunchProtection(dayStartTime, dayEndTime)) { return results; } const dayStartMinutes = timeToMinutes(dayStartTime); const dayEndMinutes = timeToMinutes(dayEndTime); for ( let slotStart = dayStartMinutes; slotStart + slotDuration <= dayEndMinutes; slotStart += slotInterval ) { const slotStartStr = minutesToTime(slotStart); const slotEndStr = minutesToTime(slotStart + slotDuration); const result = checkLunchProtection( dayStartTime, dayEndTime, existingBookings, slotStartStr, slotEndStr, isAdmin ); if (result.isBlocked || result.showWarning) { results.set(slotStartStr, result); } } return results; }