Files
Crussell/frontend/src/lib/lunchProtection.ts
T
popertots 169d7dc6e3 feat(scheduling,admin,today): week-range queries, file size limits, mobile nav UX
- Expand exceptional application query to Monday of start week
- Add 20MB file size limit with visual feedback in ImageUpload
- Reorder admin nav links, add burger badge, slide transition + backdrop
- Fetch week-range working/available hours, add closing time indicator
- Skip lunch protection for days <= 5h via shouldApplyLunchProtection
- Extract formatDateISO to shared utils
2026-06-03 10:22:16 +01:00

334 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, LunchProtectionResult> {
const results = new Map<string, LunchProtectionResult>();
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;
}