false, 'slots' => [], 'reason' => 'not_working_day']; } if ($this->is_staff_unavailable($staff_id, $date)) { return ['available' => false, 'slots' => [], 'reason' => 'staff_unavailable']; } $duration = AumReserva_Helpers::get_service_duration($service_id); $buffer = (int) get_post_meta($service_id, 'aumreserva_buffer_minutes', true); $max_per_slot = $this->get_max_slots($service_id); /* * Group departures (1.3.0): a service with fixed start times is offered at those and nowhere else — * a three-hour trip leaves at 09:00 and 13:30, not on every half hour of the site's grid. `capacity` * > 0 switches the fullness test from "how many bookings" to "how many people". */ $start_times = AumReserva_Helpers::get_service_start_times($service_id); $capacity = AumReserva_Helpers::get_service_capacity($service_id); $party_max = AumReserva_Helpers::get_service_party_max($service_id); $all_slots = $start_times ?: AumReserva_Helpers::get_time_slots($date); $hours = $this->staff_hours($staff_id); // [start, end] or null when the staff member follows the global hours $result = []; foreach ($all_slots as $slot_time) { $start_ts = (int) (new DateTimeImmutable($date . ' ' . $slot_time, wp_timezone()))->getTimestamp(); $end_ts = $start_ts + ($duration + $buffer) * 60; $end_time = (new DateTimeImmutable('@' . $end_ts))->setTimezone(wp_timezone())->format('H:i'); /* * The staff member's own hours (1.2.0). `aumreserva_staff_work_start` / `_end` had been saved by the * staff screen since 1.0 and read by nothing: a barber who works 12:00–20:00 was offered at * 09:00 like everyone else. The appointment (without buffer) has to fit inside the window. * * 1.2.1 (template #15): the comparison is on timestamps, and the window is the staff member's * hours when set, otherwise the site's. Two things the `H:i` string compare got wrong: * - a four-hour block starting at 20:00 ends at "00:00", and "00:00" > "18:00" is false — a * daylight studio closed at 18:00 was offered at 20:00, 20:30 … 22:30; * - nothing at all checked the site's closing time against the service length, so a block * starting half an hour before closing was offered whatever its duration. */ $svc_end_ts = $start_ts + $duration * 60; $win_start = $hours ? $hours[0] : (string) AumReserva_Helpers::opt('aumreserva_working_start', '09:00'); $win_end = $hours ? $hours[1] : (string) AumReserva_Helpers::opt('aumreserva_working_end', '18:00'); $win_start_ts = (int) (new DateTimeImmutable($date . ' ' . $win_start, wp_timezone()))->getTimestamp(); $win_end_ts = (int) (new DateTimeImmutable($date . ' ' . $win_end, wp_timezone()))->getTimestamp(); if ($start_ts < $win_start_ts || $svc_end_ts > $win_end_ts) { $result[] = [ 'time' => $slot_time, 'status' => 'unavailable', 'reason' => $hours ? 'staff_hours' : 'closing', ]; continue; } // Skip slots that start in the past or within min advance window $min_hours = (int) AumReserva_Helpers::opt('aumreserva_min_advance_hours', 1); if ( $start_ts < ( time() + $min_hours * 3600 ) ) { $result[] = [ 'time' => $slot_time, 'status' => 'unavailable', 'reason' => 'past', ]; continue; } // Conflict check for staff if ($staff_id > 0) { $conflicts = AumReserva_DB::get_staff_conflicts($staff_id, $date, $slot_time, $end_time); /* On a group service the same departure on the same resource is not a conflict, it is a seat. */ if ($capacity > 0) { $conflicts = array_filter($conflicts, fn($b) => (int) $b->service_id !== $service_id || substr((string) $b->start_time, 0, 5) !== $slot_time); } if (! empty($conflicts)) { $result[] = [ 'time' => $slot_time, 'status' => 'unavailable', 'reason' => 'staff_conflict', ]; continue; } } // Capacity check: people on a group service, bookings otherwise if ($capacity > 0) { $count = AumReserva_DB::get_seats_for_slot($service_id, $date, $slot_time, $staff_id); $max = $capacity; } else { $count = count(AumReserva_DB::get_bookings_for_slot($service_id, $date, $slot_time, $staff_id)); $max = $max_per_slot; } if ($count === 0) { $status = 'available'; } elseif ($count < $max) { $status = 'busy'; } else { $status = 'full'; } $slot = [ 'time' => $slot_time, 'status' => $status, 'booked' => $count, 'max' => $max, ]; if ($capacity > 0) { $slot['remaining'] = max(0, $capacity - $count); $slot['party_max'] = min($party_max, $slot['remaining']); } $result[] = $slot; } /* * A group departure with seats left is a date worth offering even when nobody is the first aboard — * "busy" only means "some seats are gone". For appointments the old rule stands: a slot somebody * already holds is not what the next caller is offered as available. */ $has_available = ! empty(array_filter($result, fn($s) => $s['status'] === 'available' || ($capacity > 0 && $s['status'] === 'busy'))); return [ 'available' => $has_available, 'slots' => $result, 'capacity' => $capacity, 'party_max' => $party_max, 'fixed_times' => ! empty($start_times), ]; } /** * Returns a summary (available | busy | full | unavailable) for each * date in the upcoming bookable window, for calendar thumbnail display. * * @param int $service_id * @param int $staff_id * @return array keyed by Y-m-d */ public function get_date_summary(int $service_id, int $staff_id = 0): array { $dates = AumReserva_Helpers::get_available_dates(); $result = []; foreach ($dates as $date) { $data = $this->get_for_date($service_id, $date, $staff_id); if (! $data['available']) { $result[$date] = 'unavailable'; continue; } $statuses = array_column($data['slots'], 'status'); if (in_array('available', $statuses, true)) { $result[$date] = 'available'; } elseif (in_array('busy', $statuses, true)) { $result[$date] = 'busy'; } else { $result[$date] = 'full'; } } return $result; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private function get_max_slots(int $service_id): int { $meta = get_post_meta($service_id, 'aumreserva_max_slots', true); if ($meta !== '' && $meta !== false) { return (int) $meta; } return (int) AumReserva_Helpers::opt('aumreserva_max_bookings_per_slot', 1); } /** * The staff member's working window for a day, when they keep their own hours. * @return array{0:string,1:string}|null `['12:00', '20:00']`, or null to follow the global hours */ private function staff_hours(int $staff_id): ?array { if ($staff_id <= 0) return null; if (! get_post_meta($staff_id, 'aumreserva_custom_hours_enabled', true)) return null; $start = (string) get_post_meta($staff_id, 'aumreserva_staff_work_start', true); $end = (string) get_post_meta($staff_id, 'aumreserva_staff_work_end', true); if (! preg_match('/^\d{2}:\d{2}$/', $start) || ! preg_match('/^\d{2}:\d{2}$/', $end) || $start >= $end) return null; return [$start, $end]; } private function is_staff_unavailable(int $staff_id, string $date): bool { if ($staff_id === 0) { return false; } $status = get_post_meta($staff_id, 'aumreserva_staff_status', true); if (in_array($status, ['vacation', 'inactive'], true)) { return true; } // Check staff-specific days off $custom_hours = get_post_meta($staff_id, 'aumreserva_custom_hours_enabled', true); if ($custom_hours) { $days_off = (array) get_post_meta($staff_id, 'aumreserva_staff_days_off', true); $day_map = [0 => 'sun', 1 => 'mon', 2 => 'tue', 3 => 'wed', 4 => 'thu', 5 => 'fri', 6 => 'sat']; $dow = (int) gmdate('w', strtotime($date)); if (in_array($day_map[$dow], $days_off, true)) { return true; } } return false; } }