From d9f2c6a37251511daca4f1f54531155f85be6d23 Mon Sep 17 00:00:00 2001 From: Gabor H Date: Mon, 12 Jan 2026 21:43:39 -0800 Subject: [PATCH] feat: Support cross-midnight working hours and appointments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow working plans with end time before start time (e.g., 18:00-02:00) for businesses operating evening/night shifts. Key changes: - Detect cross-midnight periods by checking if end <= start time - Add +1 day to end times when working hours cross midnight - Handle breaks within cross-midnight periods correctly (e.g., break at 01:00 when shift starts at 18:00) - Split multi-day appointments in calendar views for proper display - Remove validation that blocked cross-midnight configurations in working plan exceptions Calendar displays appointments spanning midnight as two connected blocks (one ending at midnight, one starting at midnight) while storing them as a single appointment in the database. Fixes #1432 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- application/libraries/Availability.php | 83 +++++++++++++++++++++--- application/models/Providers_model.php | 11 +--- assets/js/utils/calendar_default_view.js | 35 +++++++++- assets/js/utils/calendar_table_view.js | 37 +++++++++-- 4 files changed, 141 insertions(+), 25 deletions(-) diff --git a/application/libraries/Availability.php b/application/libraries/Availability.php index f6d9ec4c82..e4753546d9 100644 --- a/application/libraries/Availability.php +++ b/application/libraries/Availability.php @@ -124,10 +124,18 @@ protected function consider_multiple_attendants( return []; } + $period_start = new DateTime($date . ' ' . $date_working_plan['start']); + $period_end = new DateTime($date . ' ' . $date_working_plan['end']); + + // If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00) + if ($period_end <= $period_start) { + $period_end->modify('+1 day'); + } + $periods = [ [ - 'start' => new DateTime($date . ' ' . $date_working_plan['start']), - 'end' => new DateTime($date . ' ' . $date_working_plan['end']), + 'start' => $period_start, + 'end' => $period_end, ], ]; @@ -202,11 +210,33 @@ public function remove_breaks(string $date, array $periods, array $breaks): arra return $periods; } + // Determine the working day start time for cross-midnight handling + // by looking at the first period's start time + $working_day_start_time = null; + if (!empty($periods) && isset($periods[0]['start'])) { + $first_period_start = $periods[0]['start']; + if ($first_period_start instanceof DateTime) { + $working_day_start_time = $first_period_start->format('H:i'); + } else { + $working_day_start_time = $first_period_start; + } + } + foreach ($breaks as $break) { $break_start = new DateTime($date . ' ' . $break['start']); - $break_end = new DateTime($date . ' ' . $break['end']); + // For cross-midnight periods: if break time is before the working day start, + // the break is on the next day (e.g., break at 01:00 when work starts at 18:00) + if ($working_day_start_time !== null) { + if ($break['start'] < $working_day_start_time) { + $break_start->modify('+1 day'); + } + if ($break['end'] <= $working_day_start_time) { + $break_end->modify('+1 day'); + } + } + foreach ($periods as &$period) { $period_start = $period['start']; @@ -391,13 +421,27 @@ public function get_available_periods(string $date, array $provider, ?int $exclu 'end' => $date_working_plan['end'], ]; - $day_start = new DateTime($date_working_plan['start']); - $day_end = new DateTime($date_working_plan['end']); + $day_start = new DateTime($date . ' ' . $date_working_plan['start']); + $day_end = new DateTime($date . ' ' . $date_working_plan['end']); + + // If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00) + if ($day_end <= $day_start) { + $day_end->modify('+1 day'); + } // Split the working plan to available time periods that do not contain the breaks in them. foreach ($date_working_plan['breaks'] as $break) { - $break_start = new DateTime($break['start']); - $break_end = new DateTime($break['end']); + $break_start = new DateTime($date . ' ' . $break['start']); + $break_end = new DateTime($date . ' ' . $break['end']); + + // For cross-midnight periods: if break time is before the working day start, + // the break is on the next day (e.g., break at 01:00 when work starts at 18:00) + if ($break['start'] < $date_working_plan['start']) { + $break_start->modify('+1 day'); + } + if ($break['end'] <= $date_working_plan['start']) { + $break_end->modify('+1 day'); + } if ($break_start < $day_start) { $break_start = $day_start; @@ -412,8 +456,16 @@ public function get_available_periods(string $date, array $provider, ?int $exclu } foreach ($periods as $key => $period) { - $period_start = new DateTime($period['start']); - $period_end = new DateTime($period['end']); + $period_start = new DateTime($date . ' ' . $period['start']); + $period_end = new DateTime($date . ' ' . $period['end']); + + // Adjust for cross-midnight: if period time is before working day start, it's on the next day + if ($period['start'] < $date_working_plan['start']) { + $period_start->modify('+1 day'); + } + if ($period['end'] <= $date_working_plan['start']) { + $period_end->modify('+1 day'); + } $remove_current_period = false; @@ -459,6 +511,14 @@ public function get_available_periods(string $date, array $provider, ?int $exclu $period_start = new DateTime($date . ' ' . $period['start']); $period_end = new DateTime($date . ' ' . $period['end']); + // Adjust for cross-midnight: if period time is before working day start, it's on the next day + if ($period['start'] < $date_working_plan['start']) { + $period_start->modify('+1 day'); + } + if ($period['end'] <= $date_working_plan['start']) { + $period_end->modify('+1 day'); + } + if ( $appointment_start <= $period_start && $appointment_end <= $period_end && @@ -552,6 +612,11 @@ protected function generate_available_hours(string $date, array $service, array $end_hour = new DateTime($date . ' ' . $period['end']); + // If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00) + if ($end_hour <= $start_hour) { + $end_hour->modify('+1 day'); + } + $interval = !empty($service['slot_interval']) ? (int) $service['slot_interval'] : 15; $current_hour = $start_hour; diff --git a/application/models/Providers_model.php b/application/models/Providers_model.php index 98ac6abe75..e121de8aef 100755 --- a/application/models/Providers_model.php +++ b/application/models/Providers_model.php @@ -545,15 +545,8 @@ public function save_working_plan_exception(int $provider_id, array $working_pla throw new InvalidArgumentException('Working plan exception start date must be before or equal to end date.'); } - // If start_time and end_time are provided, validate them - if (!empty($start_time) && !empty($end_time)) { - $start = date('H:i', strtotime($start_time)); - $end = date('H:i', strtotime($end_time)); - - if ($start > $end) { - throw new InvalidArgumentException('Working plan exception start time must be before end time.'); - } - } + // Note: We do not validate start_time > end_time, as cross-midnight working hours + // (e.g., 18:00 - 02:00) are supported and handled in the Availability library // Make sure the provider record exists. $where = [ diff --git a/assets/js/utils/calendar_default_view.js b/assets/js/utils/calendar_default_view.js index c27ca724c6..7b10dbbc4b 100755 --- a/assets/js/utils/calendar_default_view.js +++ b/assets/js/utils/calendar_default_view.js @@ -871,7 +871,7 @@ App.Utils.CalendarDefaultView = (function () { * @returns {Array} Calendar event objects. */ function createAppointmentEvents(appointments) { - return appointments.map((appointment) => { + return appointments.flatMap((appointment) => { const customerName = [appointment.customer.first_name, appointment.customer.last_name] .filter(Boolean) .join(' '); @@ -883,11 +883,40 @@ App.Utils.CalendarDefaultView = (function () { const title = customerName ? customerName + ' - ' + type : appointment.service.name; + const start = moment(appointment.start_datetime); + const end = moment(appointment.end_datetime); + + // Split multi-day events for proper timeGrid display (e.g., cross-midnight appointments) + if (!start.isSame(end, 'day')) { + return [ + { + id: appointment.id + '_day1', + title, + start: start.toDate(), + end: start.clone().endOf('day').toDate(), + allDay: false, + color: appointment.color, + data: appointment, + display: 'block', + }, + { + id: appointment.id + '_day2', + title, + start: end.clone().startOf('day').toDate(), + end: end.toDate(), + allDay: false, + color: appointment.color, + data: appointment, + display: 'block', + }, + ]; + } + return { id: appointment.id, title, - start: moment(appointment.start_datetime).toDate(), - end: moment(appointment.end_datetime).toDate(), + start: start.toDate(), + end: end.toDate(), allDay: false, color: appointment.color, data: appointment, diff --git a/assets/js/utils/calendar_table_view.js b/assets/js/utils/calendar_table_view.js index 3bdbaf737c..83bfa60b51 100755 --- a/assets/js/utils/calendar_table_view.js +++ b/assets/js/utils/calendar_table_view.js @@ -839,18 +839,47 @@ App.Utils.CalendarTableView = (function () { return !filterServiceIds.length || filterServiceIds.includes(appointment.id_services); }) - .map((appointment) => { + .flatMap((appointment) => { const customerName = [appointment.customer.first_name, appointment.customer.last_name] .filter(Boolean) .join(' '); const title = customerName ? customerName + ' - ' + appointment.service.name : appointment.service.name; + const start = moment(appointment.start_datetime); + const end = moment(appointment.end_datetime); + + // Split multi-day events for proper timeGrid display (e.g., cross-midnight appointments) + if (!start.isSame(end, 'day')) { + return [ + { + id: appointment.id + '_day1', + title, + start: start.toDate(), + end: start.clone().endOf('day').toDate(), + allDay: false, + color: appointment.color, + display: 'block', + data: appointment, + }, + { + id: appointment.id + '_day2', + title, + start: end.clone().startOf('day').toDate(), + end: end.toDate(), + allDay: false, + color: appointment.color, + display: 'block', + data: appointment, + }, + ]; + } + return { id: appointment.id, - title: title, - start: moment(appointment.start_datetime).toDate(), - end: moment(appointment.end_datetime).toDate(), + title, + start: start.toDate(), + end: end.toDate(), allDay: false, color: appointment.color, display: 'block',