*/ public function slots( string $date, int $party, bool $staff = false, int $except = 0, string $hold = '' ): array { $closures = array_values( array_filter( ( new Closures() )->all( [ 'past' => 1 ] ), static fn( array $closure ): bool => $closure['starts_on'] <= $date && $date <= $closure['ends_on'] ) ); $shifts = ( new Shifts() )->all(); $combine = self::combine( $date ); [ $held, $arrivals ] = $this->booked( $date, $except ); // Each hold takes its table as a guest booking would, before this one. foreach ( self::holds( $date, $hold ) as $kept ) { $slots = Planner::slots( $date, $kept['party'], $shifts, $closures, $this->tables( false ), $held, $arrivals, PHP_INT_MIN, $combine ); foreach ( $slots as $slot ) { if ( $slot['time'] !== $kept['time'] || Planner::OPEN !== $slot['reason'] ) { continue; } $start = Planner::minutes( $slot['time'] ); foreach ( $slot['tables'] as $table_id ) { $held[] = [ 'table_id' => $table_id, 'start' => $start, 'end' => $start + $slot['duration'], ]; } $arrivals[] = [ 'start' => $start, 'party' => $kept['party'], ]; break; } } $slots = Planner::slots( $date, $party, $shifts, $closures, $this->tables( $staff ), $held, $arrivals, $staff ? PHP_INT_MIN : $this->earliest( $date ), $combine ); return $staff ? $slots : Planner::busy( $slots, self::busy_times( $date ) ); } /** * The days a guest cannot book over the next year (Planner::closed_days), * for the date calendar on the booking forms. * * @return array{weekdays:list,dates:list} */ public static function closed_days(): array { $today = current_time( 'Y-m-d' ); $until = gmdate( 'Y-m-d', (int) strtotime( $today . ' 00:00:00 UTC' ) + 366 * DAY_IN_SECONDS ); return Planner::closed_days( $today, $until, ( new Shifts() )->all(), ( new Closures() )->all() ); } /** * The filter's busy ranges as minutes from the date's midnight, checked. * * @return list */ private static function busy_times( string $date ): array { if ( ! has_filter( 'fxtr_busy_times' ) ) { return []; } $midnight = ( new DateTimeImmutable( $date . ' 00:00:00', wp_timezone() ) )->getTimestamp(); $out = []; foreach ( (array) apply_filters( 'fxtr_busy_times', [], $date ) as $range ) { if ( ! is_array( $range ) || ! is_int( $range[0] ?? null ) || ! is_int( $range[1] ?? null ) || $range[1] <= $range[0] ) { continue; } $out[] = [ intdiv( $range[0] - $midnight, 60 ), (int) ceil( ( $range[1] - $midnight ) / 60 ) ]; } return $out; } /** * The filter's holds for the date, checked, without the one with `$key`. * * @return list */ private static function holds( string $date, string $key ): array { if ( ! has_filter( 'fxtr_table_holds' ) ) { return []; } $out = []; foreach ( (array) apply_filters( 'fxtr_table_holds', [], $date, $key ) as $hold ) { if ( ! is_array( $hold ) ) { continue; } $time = (string) ( $hold['time'] ?? '' ); $party = (int) ( $hold['party'] ?? 0 ); if ( 1 !== preg_match( '/^\d{2}:\d{2}$/', $time ) || $party < 1 || $party > Tables::MAX_PARTY ) { continue; } if ( '' !== $key && (string) ( $hold['key'] ?? '' ) === $key ) { continue; } $out[] = [ 'time' => $time, 'party' => $party, ]; } return $out; } /** * @return list */ private function tables( bool $staff ): array { $offline = []; foreach ( ( new Areas() )->all() as $area ) { if ( ! $area['online'] ) { $offline[ $area['id'] ] = true; } } $out = []; foreach ( ( new Tables() )->all() as $table ) { if ( $staff || ( $table['online'] && ! isset( $offline[ $table['area_id'] ] ) ) ) { $out[] = [ 'id' => (int) $table['id'], 'min_party' => (int) $table['min_party'], 'max_party' => (int) $table['max_party'], 'sort' => (int) $table['sort'], 'group' => (string) $table['combine_group'], ]; } } return $out; } /** * The filter as Planner's callback, or null when nothing hooks it. * * @return \Closure(list
, int): list|null */ private static function combine( string $date ): ?\Closure { if ( ! has_filter( 'fxtr_combine_tables' ) ) { return null; } return static function ( array $free, int $party ) use ( $date ): array { $ids = apply_filters( 'fxtr_combine_tables', [], $free, $party, $date ); return is_array( $ids ) ? array_values( array_map( 'intval', $ids ) ) : []; }; } /** * The tables held on the date, the day before's late parties included, * and the parties arriving on it. * * @return array{0:list,1:list} */ private function booked( string $date, int $except ): array { global $wpdb; $before = gmdate( 'Y-m-d', (int) strtotime( $date . ' 00:00:00 UTC -1 day' ) ); /** @var list $rows */ $rows = (array) $wpdb->get_results( // The statuses are Tables::DONE, written out so the query stays fixed SQL. $wpdb->prepare( "SELECT r.id, r.reservation_date, r.reservation_time, r.party_size, r.duration, rt.table_id FROM %i r LEFT JOIN %i rt ON rt.reservation_id = r.id WHERE r.reservation_date IN (%s, %s) AND r.id <> %d AND r.status NOT IN ('cancelled', 'no_show', 'completed')", $wpdb->prefix . 'fxtr_reservations', $wpdb->prefix . 'fxtr_reservation_tables', $before, $date, $except ), ARRAY_A ); $held = []; $arrivals = []; foreach ( $rows as $row ) { $start = Planner::minutes( (string) $row['reservation_time'] ) - ( $row['reservation_date'] === $date ? 0 : 1440 ); if ( null !== $row['table_id'] ) { $held[] = [ 'table_id' => (int) $row['table_id'], 'start' => $start, 'end' => $start + (int) $row['duration'], ]; } // A party at two pushed-together tables arrives once. if ( $start >= 0 ) { $arrivals[ (int) $row['id'] ] = [ 'start' => $start, 'party' => (int) $row['party_size'], ]; } } return [ $held, array_values( $arrivals ) ]; } /** * The first minute of the date a guest may book: now plus the lead time, * counted from the date's midnight in the site's time zone. */ private function earliest( string $date ): int { $zone = wp_timezone(); $midnight = new DateTimeImmutable( $date . ' 00:00:00', $zone ); $now = new DateTimeImmutable( 'now', $zone ); $lead = (int) Settings::get( 'lead_time' ) * 60; return intdiv( $now->getTimestamp() - $midnight->getTimestamp(), 60 ) + $lead; } }