refactor: lunch protection to find all gaps instead of largest

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-28 16:34:13 +01:00
co-authored by Sisyphus
parent 72270649b6
commit 91d20d474b
+62 -17
View File
@@ -58,21 +58,20 @@ export function calculateMiddleWindow(
} }
/** /**
* Find the largest gap in the middle window after accounting for bookings * Find all gaps in the middle window after accounting for bookings
* Returns the duration in minutes of the largest gap * Returns an array of gap durations in minutes, sorted descending
*/ */
export function findLargestLunchGap( export function findAllLunchGaps(
middleWindowStart: number, middleWindowStart: number,
middleWindowEnd: number, middleWindowEnd: number,
existingBookings: TimeSlot[], existingBookings: TimeSlot[],
proposedBooking?: TimeSlot proposedBooking?: TimeSlot
): number { ): number[] {
const allBookings: TimeSlot[] = [...existingBookings]; const allBookings: TimeSlot[] = [...existingBookings];
if (proposedBooking) { if (proposedBooking) {
allBookings.push(proposedBooking); allBookings.push(proposedBooking);
} }
// Filter to only bookings that overlap with the middle window
const relevantBookings = allBookings const relevantBookings = allBookings
.filter((booking) => { .filter((booking) => {
const bookingStart = timeToMinutes(booking.startTime); const bookingStart = timeToMinutes(booking.startTime);
@@ -86,30 +85,49 @@ export function findLargestLunchGap(
.sort((a, b) => a.startTime - b.startTime); .sort((a, b) => a.startTime - b.startTime);
if (relevantBookings.length === 0) { if (relevantBookings.length === 0) {
return middleWindowEnd - middleWindowStart; return [middleWindowEnd - middleWindowStart];
} }
let largestGap = 0; const gaps: number[] = [];
const firstBookingStart = relevantBookings[0].startTime; const firstBookingStart = relevantBookings[0].startTime;
if (firstBookingStart > middleWindowStart) { if (firstBookingStart > middleWindowStart) {
largestGap = Math.max(largestGap, firstBookingStart - middleWindowStart); gaps.push(firstBookingStart - middleWindowStart);
} }
for (let i = 0; i < relevantBookings.length - 1; i++) { for (let i = 0; i < relevantBookings.length - 1; i++) {
const gapStart = relevantBookings[i].endTime; const gapStart = relevantBookings[i].endTime;
const gapEnd = relevantBookings[i + 1].startTime; const gapEnd = relevantBookings[i + 1].startTime;
if (gapEnd > gapStart) { if (gapEnd > gapStart) {
largestGap = Math.max(largestGap, gapEnd - gapStart); gaps.push(gapEnd - gapStart);
} }
} }
const lastBookingEnd = relevantBookings[relevantBookings.length - 1].endTime; const lastBookingEnd = relevantBookings[relevantBookings.length - 1].endTime;
if (lastBookingEnd < middleWindowEnd) { if (lastBookingEnd < middleWindowEnd) {
largestGap = Math.max(largestGap, middleWindowEnd - lastBookingEnd); gaps.push(middleWindowEnd - lastBookingEnd);
} }
return largestGap; 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_USER = 60;
@@ -141,10 +159,12 @@ export function checkLunchProtection(
endTime: proposedSlotEnd endTime: proposedSlotEnd
}; };
const largestGap = findLargestLunchGap(windowStart, windowEnd, existingBookings, proposedBooking); const gapWithout = findLargestLunchGap(windowStart, windowEnd, existingBookings);
const gapsWith = findAllLunchGaps(windowStart, windowEnd, existingBookings, proposedBooking);
const gapWith = gapsWith[0] ?? 0;
if (isAdmin) { if (isAdmin) {
if (largestGap < LUNCH_MINIMUM_ADMIN) { if (gapWith < LUNCH_MINIMUM_ADMIN && gapWith < gapWithout) {
return { return {
isBlocked: true, isBlocked: true,
showWarning: false, showWarning: false,
@@ -152,18 +172,43 @@ export function checkLunchProtection(
}; };
} }
if (largestGap < LUNCH_WARNING_THRESHOLD) { if (gapWith < LUNCH_WARNING_THRESHOLD && gapWith < gapWithout) {
const gapMinutes = Math.round(largestGap); 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 { return {
isBlocked: false, isBlocked: false,
showWarning: true, showWarning: true,
warningMessage: `Warning: This booking would reduce lunch break to ${gapMinutes} minutes.` 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 }; return { isBlocked: false, showWarning: false };
} else { } else {
if (largestGap < LUNCH_MINIMUM_USER) { if (gapWith < LUNCH_MINIMUM_USER && gapWith < gapWithout) {
return { return {
isBlocked: true, isBlocked: true,
showWarning: false, showWarning: false,