Skip to content

Commit d9f2c6a

Browse files
gabor-hclaude
andcommitted
feat: Support cross-midnight working hours and appointments
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 <noreply@anthropic.com>
1 parent 94acf76 commit d9f2c6a

4 files changed

Lines changed: 141 additions & 25 deletions

File tree

application/libraries/Availability.php

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,18 @@ protected function consider_multiple_attendants(
124124
return [];
125125
}
126126

127+
$period_start = new DateTime($date . ' ' . $date_working_plan['start']);
128+
$period_end = new DateTime($date . ' ' . $date_working_plan['end']);
129+
130+
// If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00)
131+
if ($period_end <= $period_start) {
132+
$period_end->modify('+1 day');
133+
}
134+
127135
$periods = [
128136
[
129-
'start' => new DateTime($date . ' ' . $date_working_plan['start']),
130-
'end' => new DateTime($date . ' ' . $date_working_plan['end']),
137+
'start' => $period_start,
138+
'end' => $period_end,
131139
],
132140
];
133141

@@ -202,11 +210,33 @@ public function remove_breaks(string $date, array $periods, array $breaks): arra
202210
return $periods;
203211
}
204212

213+
// Determine the working day start time for cross-midnight handling
214+
// by looking at the first period's start time
215+
$working_day_start_time = null;
216+
if (!empty($periods) && isset($periods[0]['start'])) {
217+
$first_period_start = $periods[0]['start'];
218+
if ($first_period_start instanceof DateTime) {
219+
$working_day_start_time = $first_period_start->format('H:i');
220+
} else {
221+
$working_day_start_time = $first_period_start;
222+
}
223+
}
224+
205225
foreach ($breaks as $break) {
206226
$break_start = new DateTime($date . ' ' . $break['start']);
207-
208227
$break_end = new DateTime($date . ' ' . $break['end']);
209228

229+
// For cross-midnight periods: if break time is before the working day start,
230+
// the break is on the next day (e.g., break at 01:00 when work starts at 18:00)
231+
if ($working_day_start_time !== null) {
232+
if ($break['start'] < $working_day_start_time) {
233+
$break_start->modify('+1 day');
234+
}
235+
if ($break['end'] <= $working_day_start_time) {
236+
$break_end->modify('+1 day');
237+
}
238+
}
239+
210240
foreach ($periods as &$period) {
211241
$period_start = $period['start'];
212242

@@ -391,13 +421,27 @@ public function get_available_periods(string $date, array $provider, ?int $exclu
391421
'end' => $date_working_plan['end'],
392422
];
393423

394-
$day_start = new DateTime($date_working_plan['start']);
395-
$day_end = new DateTime($date_working_plan['end']);
424+
$day_start = new DateTime($date . ' ' . $date_working_plan['start']);
425+
$day_end = new DateTime($date . ' ' . $date_working_plan['end']);
426+
427+
// If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00)
428+
if ($day_end <= $day_start) {
429+
$day_end->modify('+1 day');
430+
}
396431

397432
// Split the working plan to available time periods that do not contain the breaks in them.
398433
foreach ($date_working_plan['breaks'] as $break) {
399-
$break_start = new DateTime($break['start']);
400-
$break_end = new DateTime($break['end']);
434+
$break_start = new DateTime($date . ' ' . $break['start']);
435+
$break_end = new DateTime($date . ' ' . $break['end']);
436+
437+
// For cross-midnight periods: if break time is before the working day start,
438+
// the break is on the next day (e.g., break at 01:00 when work starts at 18:00)
439+
if ($break['start'] < $date_working_plan['start']) {
440+
$break_start->modify('+1 day');
441+
}
442+
if ($break['end'] <= $date_working_plan['start']) {
443+
$break_end->modify('+1 day');
444+
}
401445

402446
if ($break_start < $day_start) {
403447
$break_start = $day_start;
@@ -412,8 +456,16 @@ public function get_available_periods(string $date, array $provider, ?int $exclu
412456
}
413457

414458
foreach ($periods as $key => $period) {
415-
$period_start = new DateTime($period['start']);
416-
$period_end = new DateTime($period['end']);
459+
$period_start = new DateTime($date . ' ' . $period['start']);
460+
$period_end = new DateTime($date . ' ' . $period['end']);
461+
462+
// Adjust for cross-midnight: if period time is before working day start, it's on the next day
463+
if ($period['start'] < $date_working_plan['start']) {
464+
$period_start->modify('+1 day');
465+
}
466+
if ($period['end'] <= $date_working_plan['start']) {
467+
$period_end->modify('+1 day');
468+
}
417469

418470
$remove_current_period = false;
419471

@@ -459,6 +511,14 @@ public function get_available_periods(string $date, array $provider, ?int $exclu
459511
$period_start = new DateTime($date . ' ' . $period['start']);
460512
$period_end = new DateTime($date . ' ' . $period['end']);
461513

514+
// Adjust for cross-midnight: if period time is before working day start, it's on the next day
515+
if ($period['start'] < $date_working_plan['start']) {
516+
$period_start->modify('+1 day');
517+
}
518+
if ($period['end'] <= $date_working_plan['start']) {
519+
$period_end->modify('+1 day');
520+
}
521+
462522
if (
463523
$appointment_start <= $period_start &&
464524
$appointment_end <= $period_end &&
@@ -552,6 +612,11 @@ protected function generate_available_hours(string $date, array $service, array
552612

553613
$end_hour = new DateTime($date . ' ' . $period['end']);
554614

615+
// If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00)
616+
if ($end_hour <= $start_hour) {
617+
$end_hour->modify('+1 day');
618+
}
619+
555620
$interval = !empty($service['slot_interval']) ? (int) $service['slot_interval'] : 15;
556621

557622
$current_hour = $start_hour;

application/models/Providers_model.php

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -545,15 +545,8 @@ public function save_working_plan_exception(int $provider_id, array $working_pla
545545
throw new InvalidArgumentException('Working plan exception start date must be before or equal to end date.');
546546
}
547547

548-
// If start_time and end_time are provided, validate them
549-
if (!empty($start_time) && !empty($end_time)) {
550-
$start = date('H:i', strtotime($start_time));
551-
$end = date('H:i', strtotime($end_time));
552-
553-
if ($start > $end) {
554-
throw new InvalidArgumentException('Working plan exception start time must be before end time.');
555-
}
556-
}
548+
// Note: We do not validate start_time > end_time, as cross-midnight working hours
549+
// (e.g., 18:00 - 02:00) are supported and handled in the Availability library
557550

558551
// Make sure the provider record exists.
559552
$where = [

assets/js/utils/calendar_default_view.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,7 @@ App.Utils.CalendarDefaultView = (function () {
871871
* @returns {Array} Calendar event objects.
872872
*/
873873
function createAppointmentEvents(appointments) {
874-
return appointments.map((appointment) => {
874+
return appointments.flatMap((appointment) => {
875875
const customerName = [appointment.customer.first_name, appointment.customer.last_name]
876876
.filter(Boolean)
877877
.join(' ');
@@ -883,11 +883,40 @@ App.Utils.CalendarDefaultView = (function () {
883883

884884
const title = customerName ? customerName + ' - ' + type : appointment.service.name;
885885

886+
const start = moment(appointment.start_datetime);
887+
const end = moment(appointment.end_datetime);
888+
889+
// Split multi-day events for proper timeGrid display (e.g., cross-midnight appointments)
890+
if (!start.isSame(end, 'day')) {
891+
return [
892+
{
893+
id: appointment.id + '_day1',
894+
title,
895+
start: start.toDate(),
896+
end: start.clone().endOf('day').toDate(),
897+
allDay: false,
898+
color: appointment.color,
899+
data: appointment,
900+
display: 'block',
901+
},
902+
{
903+
id: appointment.id + '_day2',
904+
title,
905+
start: end.clone().startOf('day').toDate(),
906+
end: end.toDate(),
907+
allDay: false,
908+
color: appointment.color,
909+
data: appointment,
910+
display: 'block',
911+
},
912+
];
913+
}
914+
886915
return {
887916
id: appointment.id,
888917
title,
889-
start: moment(appointment.start_datetime).toDate(),
890-
end: moment(appointment.end_datetime).toDate(),
918+
start: start.toDate(),
919+
end: end.toDate(),
891920
allDay: false,
892921
color: appointment.color,
893922
data: appointment,

assets/js/utils/calendar_table_view.js

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -839,18 +839,47 @@ App.Utils.CalendarTableView = (function () {
839839

840840
return !filterServiceIds.length || filterServiceIds.includes(appointment.id_services);
841841
})
842-
.map((appointment) => {
842+
.flatMap((appointment) => {
843843
const customerName = [appointment.customer.first_name, appointment.customer.last_name]
844844
.filter(Boolean)
845845
.join(' ');
846846

847847
const title = customerName ? customerName + ' - ' + appointment.service.name : appointment.service.name;
848848

849+
const start = moment(appointment.start_datetime);
850+
const end = moment(appointment.end_datetime);
851+
852+
// Split multi-day events for proper timeGrid display (e.g., cross-midnight appointments)
853+
if (!start.isSame(end, 'day')) {
854+
return [
855+
{
856+
id: appointment.id + '_day1',
857+
title,
858+
start: start.toDate(),
859+
end: start.clone().endOf('day').toDate(),
860+
allDay: false,
861+
color: appointment.color,
862+
display: 'block',
863+
data: appointment,
864+
},
865+
{
866+
id: appointment.id + '_day2',
867+
title,
868+
start: end.clone().startOf('day').toDate(),
869+
end: end.toDate(),
870+
allDay: false,
871+
color: appointment.color,
872+
display: 'block',
873+
data: appointment,
874+
},
875+
];
876+
}
877+
849878
return {
850879
id: appointment.id,
851-
title: title,
852-
start: moment(appointment.start_datetime).toDate(),
853-
end: moment(appointment.end_datetime).toDate(),
880+
title,
881+
start: start.toDate(),
882+
end: end.toDate(),
854883
allDay: false,
855884
color: appointment.color,
856885
display: 'block',

0 commit comments

Comments
 (0)