Fix lunch break: float-to-gap logic + Google Calendar sync

- Lunch slot no longer pre-blocked; a slot is only unavailable if
  booking it would eliminate the last possible 30-min break window
- Added preferred lunch time per worker (closest-to-preferred slot wins)
- Lunch break only applies for shifts >= 6 hours
- Google Calendar: lunch event created/updated/deleted on every
  booking create, modify, or cancel via _syncLunchCalendarEvent()
- New table worker_lunch_gcal_events tracks lunch event IDs per worker/date
- New model methods: getBookingsForWorkerDay, getLunchGcalEventId,
  upsertLunchGcalEventId, deleteLunchGcalEventRecord, getBookingsForWorkerMonth,
  computeLunchBreak

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 21:12:08 +01:00
co-authored by Claude Sonnet 4.6
parent b8f4f67b23
commit f30d8d1a07
2 changed files with 455 additions and 397 deletions
+57 -64
View File
@@ -488,65 +488,18 @@ class Service_model extends CI_Model {
log_message('error', '⏳ Min start (next 15min +1h, Europe/Oslo): ' . $dayStartTime->format('Y-m-d H:i:s'));
}
// --- Lunch break: find free 30-min slot closest to preferred time within worker's lunch window ---
$lunchBlockStart = null;
$lunchBlockEnd = null;
$workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = '$worker_id' LIMIT 1")->row();
// --- Lunch break setup ---
// Pre-fetch all bookings for the day so we can do hypothetical checks per slot
$workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = '$worker_id' LIMIT 1")->row();
$shiftSeconds = $finishTime->getTimestamp() - $dayStartTime->getTimestamp();
if ($workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600) {
$lunchRequired = $workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600;
$allBookings = [];
if ($lunchRequired) {
$allBookings = $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings
WHERE booking_date = '$selectedDate' AND worker_id = '$worker_id'
")->result();
$lunchWinStart = new DateTime($selectedDate . ' ' . $workerRow->lunch_window_start);
$lunchWinEnd = new DateTime($selectedDate . ' ' . $workerRow->lunch_window_end);
$lunchDuration = new DateInterval('PT30M');
$lunchStep = new DateInterval('PT15M');
// Clamp window to actual shift hours
if ($lunchWinStart < $dayStartTime) $lunchWinStart = clone $dayStartTime;
if ($lunchWinEnd > $finishTime) $lunchWinEnd = clone $finishTime;
$scanEnd = (clone $lunchWinEnd)->sub($lunchDuration);
$isFree = function($c) use ($lunchDuration, $allBookings, $selectedDate) {
$cEnd = (clone $c)->add($lunchDuration);
foreach ($allBookings as $bk) {
$bkS = new DateTime($selectedDate . ' ' . $bk->booking_start_time);
$bkE = new DateTime($selectedDate . ' ' . $bk->booking_finish_time);
if ($bkS < $cEnd && $bkE > $c) return false;
}
return true;
};
// Collect free slots inside window, pick closest to preferred time
$freeSlots = [];
for ($c = clone $lunchWinStart; $c <= $scanEnd; $c->add($lunchStep)) {
if ($isFree($c)) $freeSlots[] = clone $c;
}
if (!empty($freeSlots)) {
$prefDt = $workerRow->lunch_preferred_time
? new DateTime($selectedDate . ' ' . $workerRow->lunch_preferred_time)
: clone $lunchWinStart;
$best = null;
$bestDiff = PHP_INT_MAX;
foreach ($freeSlots as $slot) {
$diff = abs($slot->getTimestamp() - $prefDt->getTimestamp());
if ($diff < $bestDiff) { $bestDiff = $diff; $best = $slot; }
}
$lunchBlockStart = $best;
$lunchBlockEnd = (clone $best)->add($lunchDuration);
} else {
// Fallback: search after the window
for ($c = clone $lunchWinEnd; ; $c->add($lunchStep)) {
$candEnd = (clone $c)->add($lunchDuration);
if ($candEnd > $finishTime) break;
if ($isFree($c)) { $lunchBlockStart = clone $c; $lunchBlockEnd = $candEnd; break; }
}
}
}
$interval = new DateInterval('PT15M');
@@ -562,23 +515,33 @@ class Service_model extends CI_Model {
$endTimeStr = $slotEnd->format('H:i:s');
$bookingQuery = $this->db->query("
SELECT * FROM bookings
WHERE booking_date = '{$selectedDate}'
AND worker_id = '{$worker_id}'
AND (
(booking_start_time < '{$endTimeStr}' AND booking_finish_time > '{$startTimeStr}')
)
SELECT * FROM bookings
WHERE booking_date = '{$selectedDate}'
AND worker_id = '{$worker_id}'
AND booking_start_time < '{$endTimeStr}' AND booking_finish_time > '{$startTimeStr}'
");
$overlapsLunch = $lunchBlockStart && ($current < $lunchBlockEnd) && ($slotEnd > $lunchBlockStart);
if ($bookingQuery->num_rows() == 0 && !$overlapsLunch) {
$availableTimeArray[] = $startTimeStr;
if ($bookingQuery->num_rows() > 0) continue; // already booked
if ($lunchRequired) {
// Check: if we book this slot, can a 30-min lunch break still fit somewhere?
$hypo = array_merge((array)$allBookings, [(object)[
'booking_start_time' => $startTimeStr,
'booking_finish_time' => $endTimeStr,
]]);
$canStillBreak = $this->computeLunchBreak(
$workerRow->lunch_window_start, $workerRow->lunch_window_end,
$selectedDate, $hypo, $finishTime, $dayStartTime,
$workerRow->lunch_preferred_time
) !== null;
if (!$canStillBreak) continue; // booking this slot would eliminate the only break opportunity
}
$availableTimeArray[] = $startTimeStr;
log_message('debug', 'Selected Date: ' . $selectedDate);
log_message('debug', 'Weekday: ' . (isset($weekday) ? $weekday : 'N/A'));
log_message('debug', 'Worker ID: ' . $worker_id);
log_message('debug', 'Query: ' . $this->db->last_query());
}
return $availableTimeArray;
@@ -669,6 +632,36 @@ class Service_model extends CI_Model {
$this->db->insert('worker_schedule_overrides', $override);
}
public function getBookingsForWorkerDay($worker_id, $date) {
$this->load->database();
return $this->db->query("
SELECT booking_start_time, booking_finish_time FROM bookings
WHERE worker_id = ? AND booking_date = ?
ORDER BY booking_start_time
", [$worker_id, $date])->result();
}
public function getLunchGcalEventId($worker_id, $date) {
$this->load->database();
$row = $this->db->query("SELECT gcal_event_id FROM worker_lunch_gcal_events WHERE worker_id = ? AND date = ? LIMIT 1", [$worker_id, $date])->row();
return $row ? $row->gcal_event_id : null;
}
public function upsertLunchGcalEventId($worker_id, $date, $event_id) {
$this->load->database();
$existing = $this->getLunchGcalEventId($worker_id, $date);
if ($existing) {
$this->db->where('worker_id', $worker_id)->where('date', $date)->update('worker_lunch_gcal_events', ['gcal_event_id' => $event_id]);
} else {
$this->db->insert('worker_lunch_gcal_events', ['worker_id' => $worker_id, 'date' => $date, 'gcal_event_id' => $event_id]);
}
}
public function deleteLunchGcalEventRecord($worker_id, $date) {
$this->load->database();
$this->db->where('worker_id', $worker_id)->where('date', $date)->delete('worker_lunch_gcal_events');
}
public function getBookingsForWorkerMonth($worker_id, $year, $month) {
$this->load->database();
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);