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:
@@ -460,6 +460,9 @@ class Pages extends CI_Controller {
|
|||||||
$this->Service_model->updateBookingCalEvents($newBooking->booking_id, $workerEventId, $ownerEventId);
|
$this->Service_model->updateBookingCalEvents($newBooking->booking_id, $workerEventId, $ownerEventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync lunch break calendar event for this worker+date
|
||||||
|
$this->_syncLunchCalendarEvent($worker, $bookingArray['booking_date']);
|
||||||
|
|
||||||
// ntfy push notification
|
// ntfy push notification
|
||||||
$gcalServiceNamesNtfy = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
|
$gcalServiceNamesNtfy = implode(', ', array_map(function($s){ return $s->service_name_no; }, $serviceArray));
|
||||||
$this->_ntfy(
|
$this->_ntfy(
|
||||||
@@ -890,6 +893,12 @@ class Pages extends CI_Controller {
|
|||||||
}
|
}
|
||||||
$this->Service_model->updateBookingCalEvents($booking->booking_id, $newWorkerEventId, $newOwnerEventId);
|
$this->Service_model->updateBookingCalEvents($booking->booking_id, $newWorkerEventId, $newOwnerEventId);
|
||||||
|
|
||||||
|
// Sync lunch break calendar event (new date, and old date if it changed)
|
||||||
|
$this->_syncLunchCalendarEvent($worker, $newBookingDate);
|
||||||
|
if ($booking->booking_date !== $newBookingDate) {
|
||||||
|
$this->_syncLunchCalendarEvent($worker, $booking->booking_date);
|
||||||
|
}
|
||||||
|
|
||||||
// ntfy push notification
|
// ntfy push notification
|
||||||
$this->_ntfy(
|
$this->_ntfy(
|
||||||
'Modified booking: ' . $booking->guest_name,
|
'Modified booking: ' . $booking->guest_name,
|
||||||
@@ -1029,6 +1038,9 @@ class Pages extends CI_Controller {
|
|||||||
|
|
||||||
$this->Service_model->deleteBooking($booking->booking_id);
|
$this->Service_model->deleteBooking($booking->booking_id);
|
||||||
|
|
||||||
|
// Sync lunch break calendar event now that the booking is gone
|
||||||
|
$this->_syncLunchCalendarEvent($worker, $booking->booking_date);
|
||||||
|
|
||||||
$data['selectedLang'] = $lang;
|
$data['selectedLang'] = $lang;
|
||||||
$data['booking'] = $booking;
|
$data['booking'] = $booking;
|
||||||
$data['worker'] = $worker;
|
$data['worker'] = $worker;
|
||||||
@@ -1036,6 +1048,59 @@ class Pages extends CI_Controller {
|
|||||||
$this->load->view('pages/manage-booking-cancelled', $data);
|
$this->load->view('pages/manage-booking-cancelled', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function _syncLunchCalendarEvent($worker, $date) {
|
||||||
|
if (empty($worker->google_calendar_id) || empty($worker->lunch_window_start) || empty($worker->lunch_window_end)) return;
|
||||||
|
|
||||||
|
// Determine shift hours for this date
|
||||||
|
$override = $this->Service_model->getWorkerScheduleOverrideByDate($worker->worker_id, $date);
|
||||||
|
if ($override && $override->is_day_off) {
|
||||||
|
$this->_deleteLunchGcalEvent($worker, $date);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($override && $override->start_time && $override->end_time) {
|
||||||
|
$shiftStart = new DateTime($date . ' ' . $override->start_time);
|
||||||
|
$shiftEnd = new DateTime($date . ' ' . $override->end_time);
|
||||||
|
} else {
|
||||||
|
$phpWeekday = (int)date('w', strtotime($date));
|
||||||
|
$schedule = $this->Service_model->getWorkerScheduleByDay($worker->worker_id, $phpWeekday);
|
||||||
|
if (!$schedule) { $this->_deleteLunchGcalEvent($worker, $date); return; }
|
||||||
|
$shiftStart = new DateTime($date . ' ' . $schedule->start_time);
|
||||||
|
$shiftEnd = new DateTime($date . ' ' . $schedule->end_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayBookings = $this->Service_model->getBookingsForWorkerDay($worker->worker_id, $date);
|
||||||
|
$lunch = $this->Service_model->computeLunchBreak(
|
||||||
|
$worker->lunch_window_start, $worker->lunch_window_end,
|
||||||
|
$date, $dayBookings, $shiftEnd, $shiftStart,
|
||||||
|
isset($worker->lunch_preferred_time) ? $worker->lunch_preferred_time : null
|
||||||
|
);
|
||||||
|
|
||||||
|
$existingEventId = $this->Service_model->getLunchGcalEventId($worker->worker_id, $date);
|
||||||
|
if ($lunch) {
|
||||||
|
$lunchStart = $date . 'T' . $lunch['start'] . ':00';
|
||||||
|
$lunchEnd = $date . 'T' . $lunch['end'] . ':00';
|
||||||
|
$title = 'Ebédszünet — ' . $worker->worker_name;
|
||||||
|
$desc = 'Automatikusan számított ebédszünet.';
|
||||||
|
if ($existingEventId) {
|
||||||
|
$this->googlecalendar->updateEvent($worker->google_calendar_id, $existingEventId, $title, $desc, $lunchStart, $lunchEnd);
|
||||||
|
} else {
|
||||||
|
$newId = $this->googlecalendar->createEvent($worker->google_calendar_id, $title, $desc, $lunchStart, $lunchEnd);
|
||||||
|
if ($newId) $this->Service_model->upsertLunchGcalEventId($worker->worker_id, $date, $newId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->_deleteLunchGcalEvent($worker, $date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _deleteLunchGcalEvent($worker, $date) {
|
||||||
|
if (empty($worker->google_calendar_id)) return;
|
||||||
|
$eventId = $this->Service_model->getLunchGcalEventId($worker->worker_id, $date);
|
||||||
|
if ($eventId) {
|
||||||
|
$this->googlecalendar->deleteEvent($worker->google_calendar_id, $eventId);
|
||||||
|
$this->Service_model->deleteLunchGcalEventRecord($worker->worker_id, $date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function _ntfy($title, $message, $workerTopic = null){
|
private function _ntfy($title, $message, $workerTopic = null){
|
||||||
$this->config->load('google_calendar');
|
$this->config->load('google_calendar');
|
||||||
$evelinTopic = $this->config->item('ntfy_topic');
|
$evelinTopic = $this->config->item('ntfy_topic');
|
||||||
|
|||||||
@@ -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'));
|
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 ---
|
// --- Lunch break setup ---
|
||||||
$lunchBlockStart = null;
|
// Pre-fetch all bookings for the day so we can do hypothetical checks per slot
|
||||||
$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();
|
$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();
|
$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("
|
$allBookings = $this->db->query("
|
||||||
SELECT booking_start_time, booking_finish_time FROM bookings
|
SELECT booking_start_time, booking_finish_time FROM bookings
|
||||||
WHERE booking_date = '$selectedDate' AND worker_id = '$worker_id'
|
WHERE booking_date = '$selectedDate' AND worker_id = '$worker_id'
|
||||||
")->result();
|
")->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');
|
$interval = new DateInterval('PT15M');
|
||||||
@@ -565,20 +518,30 @@ class Service_model extends CI_Model {
|
|||||||
SELECT * FROM bookings
|
SELECT * FROM bookings
|
||||||
WHERE booking_date = '{$selectedDate}'
|
WHERE booking_date = '{$selectedDate}'
|
||||||
AND worker_id = '{$worker_id}'
|
AND worker_id = '{$worker_id}'
|
||||||
AND (
|
AND booking_start_time < '{$endTimeStr}' AND booking_finish_time > '{$startTimeStr}'
|
||||||
(booking_start_time < '{$endTimeStr}' AND booking_finish_time > '{$startTimeStr}')
|
|
||||||
)
|
|
||||||
");
|
");
|
||||||
|
|
||||||
$overlapsLunch = $lunchBlockStart && ($current < $lunchBlockEnd) && ($slotEnd > $lunchBlockStart);
|
if ($bookingQuery->num_rows() > 0) continue; // already booked
|
||||||
if ($bookingQuery->num_rows() == 0 && !$overlapsLunch) {
|
|
||||||
$availableTimeArray[] = $startTimeStr;
|
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', 'Selected Date: ' . $selectedDate);
|
||||||
log_message('debug', 'Weekday: ' . (isset($weekday) ? $weekday : 'N/A'));
|
log_message('debug', 'Weekday: ' . (isset($weekday) ? $weekday : 'N/A'));
|
||||||
log_message('debug', 'Worker ID: ' . $worker_id);
|
log_message('debug', 'Worker ID: ' . $worker_id);
|
||||||
log_message('debug', 'Query: ' . $this->db->last_query());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $availableTimeArray;
|
return $availableTimeArray;
|
||||||
@@ -669,6 +632,36 @@ class Service_model extends CI_Model {
|
|||||||
$this->db->insert('worker_schedule_overrides', $override);
|
$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) {
|
public function getBookingsForWorkerMonth($worker_id, $year, $month) {
|
||||||
$this->load->database();
|
$this->load->database();
|
||||||
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);
|
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||||
|
|||||||
Reference in New Issue
Block a user