- 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
334 lines
9.5 KiB
TypeScript
334 lines
9.5 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|