,opens:string,last_seating:string,slot_interval:int,max_covers:int,turn_default:int,turn_times:list,active:bool} * @phpstan-type Closure array{starts_on:string,ends_on:string,shift_id:int} * @phpstan-type Table array{id:int,min_party:int,max_party:int,sort:int,group?:string} * @phpstan-type Held array{table_id:int,start:int,end:int} * @phpstan-type Arrival array{start:int,party:int} * @phpstan-type Slot array{time:string,shift_id:int,duration:int,table_id:int,tables:list,reason:string} * @phpstan-type Combine \Closure(list, int): list */ final class Planner { /** Bookable. */ public const OPEN = ''; /** Too soon, or already past. */ public const TOO_SOON = 'too_soon'; /** The kitchen has its fill of guests arriving then. */ public const COVERS = 'covers'; /** No table that seats the party is free for the whole stay. */ public const NO_TABLE = 'no_table'; /** The owner blocked the time, for a private party or a repair. */ public const BUSY = 'busy'; /** * Every booking time on the date, bookable or not, in time order. * * @param list $shifts * @param list $closures * @param list
$tables The tables this booking may use. * @param list $held Tables already taken. * @param list $arrivals Parties already booked, by arrival. * @param Combine|null $combine Given the tables free for the whole stay * and the party, the ids of those to push * together; [] when none will do. * @return list */ public static function slots( string $date, int $party, array $shifts, array $closures, array $tables, array $held, array $arrivals, int $earliest, ?\Closure $combine = null ): array { $out = []; foreach ( self::open_shifts( $date, $shifts, $closures ) as $shift ) { $duration = self::turn( $shift, $party ); $interval = max( 5, $shift['slot_interval'] ); $last = self::minutes( $shift['last_seating'] ); for ( $start = self::minutes( $shift['opens'] ); $start <= $last; $start += $interval ) { $picked = []; $reason = self::OPEN; if ( $start < $earliest ) { $reason = self::TOO_SOON; } elseif ( $shift['max_covers'] > 0 && self::arriving( $arrivals, $start, $interval ) + $party > $shift['max_covers'] ) { $reason = self::COVERS; } else { $table = self::fit( $tables, $party, $held, $start, $start + $duration ); $picked = 0 === $table ? self::joined( $combine, $tables, $party, $held, $start, $start + $duration ) : [ $table ]; $reason = [] === $picked ? self::NO_TABLE : self::OPEN; } $out[] = [ 'time' => self::clock( $start ), 'shift_id' => $shift['id'], 'duration' => $duration, 'table_id' => $picked[0] ?? 0, 'tables' => $picked, 'reason' => $reason, ]; } } usort( $out, static fn( array $a, array $b ): int => strcmp( $a['time'], $b['time'] ) ); return $out; } /** * Marks the open slots whose stay overlaps a busy range as BUSY, and * frees the table they would have taken. Back to back is fine, as for * tables: a range that ends at 19:00 leaves the 19:00 slot open. * * @param list $slots * @param list $ranges Minutes from the day's midnight, start and end. * @return list */ public static function busy( array $slots, array $ranges ): array { if ( [] === $ranges ) { return $slots; } foreach ( $slots as $i => $slot ) { if ( self::OPEN !== $slot['reason'] ) { continue; } $start = self::minutes( $slot['time'] ); $end = $start + $slot['duration']; foreach ( $ranges as [ $from, $until ] ) { if ( $from < $end && $start < $until ) { $slots[ $i ]['reason'] = self::BUSY; $slots[ $i ]['table_id'] = 0; $slots[ $i ]['tables'] = []; break; } } } return $slots; } /** * The active shifts that run on the date's weekday and are not closed * that day, whole or alone. * * @param list $shifts * @param list $closures * @return list */ public static function open_shifts( string $date, array $shifts, array $closures ): array { $time = strtotime( $date . ' 00:00:00 UTC' ); if ( false === $time ) { return []; } $weekday = (int) gmdate( 'w', $time ); $closed = []; foreach ( $closures as $closure ) { if ( $closure['starts_on'] <= $date && $date <= $closure['ends_on'] ) { $closed[ $closure['shift_id'] ] = true; } } if ( isset( $closed[0] ) ) { return []; } return array_values( array_filter( $shifts, static fn( array $shift ): bool => $shift['active'] && in_array( $weekday, $shift['days'], true ) && ! isset( $closed[ $shift['id'] ] ) ) ); } /** * The days a guest cannot book at all, for the calendar to grey out: * the weekdays no active shift runs on (0 = Sunday), and the dates from * `$from` through `$until` that closures shut on another weekday. * * @param list $shifts * @param list $closures * @return array{weekdays:list,dates:list} */ public static function closed_days( string $from, string $until, array $shifts, array $closures ): array { $open = []; foreach ( $shifts as $shift ) { if ( $shift['active'] ) { foreach ( $shift['days'] as $day ) { $open[ $day ] = true; } } } $weekdays = array_values( array_diff( range( 0, 6 ), array_keys( $open ) ) ); $dates = []; foreach ( $closures as $closure ) { $day = strtotime( max( $from, $closure['starts_on'] ) . ' 00:00:00 UTC' ); $last = strtotime( min( $until, $closure['ends_on'] ) . ' 00:00:00 UTC' ); if ( false === $day || false === $last ) { continue; } for ( ; $day <= $last; $day += DAY_IN_SECONDS ) { $date = gmdate( 'Y-m-d', $day ); if ( ! isset( $dates[ $date ] ) && isset( $open[ (int) gmdate( 'w', $day ) ] ) && [] === self::open_shifts( $date, $shifts, $closures ) ) { $dates[ $date ] = true; } } } $dates = array_keys( $dates ); sort( $dates ); return [ 'weekdays' => $weekdays, 'dates' => $dates, ]; } /** * Minutes the party keeps its table: the first turn_times step big * enough for it, or the shift's default for a bigger party. * * @param Shift $shift */ public static function turn( array $shift, int $party ): int { $steps = $shift['turn_times']; usort( $steps, static fn( array $a, array $b ): int => $a['party'] <=> $b['party'] ); foreach ( $steps as $step ) { if ( $party <= $step['party'] ) { return $step['minutes']; } } return $shift['turn_default']; } /** * The best table for the party from $start to $end: one that seats it, * is free all that time and has the fewest seats; then the owner's * order. 0 when there is none. * * @param list
$tables * @param list $held */ public static function fit( array $tables, int $party, array $held, int $start, int $end ): int { $best = null; foreach ( self::free( $tables, $held, $start, $end ) as $table ) { if ( $party < $table['min_party'] || $party > $table['max_party'] ) { continue; } if ( null === $best || self::better( $table, $best ) ) { $best = $table; } } return null === $best ? 0 : $best['id']; } /** * The tables nobody holds from $start to $end. * * @param list
$tables * @param list $held * @return list
*/ public static function free( array $tables, array $held, int $start, int $end ): array { $taken = []; foreach ( $held as $hold ) { // Back to back is fine: a table freed at 19:30 seats the 19:30 party. if ( $hold['start'] < $end && $start < $hold['end'] ) { $taken[ $hold['table_id'] ] = true; } } return array_values( array_filter( $tables, static fn( array $table ): bool => ! isset( $taken[ $table['id'] ] ) ) ); } /** * What $combine joins for the party, checked: two or more free tables, * each once, that seat it between them. Anything else is [], so a * careless callback cannot hand out a busy or unknown table. * * @param Combine|null $combine * @param list
$tables * @param list $held * @return list */ private static function joined( ?\Closure $combine, array $tables, int $party, array $held, int $start, int $end ): array { if ( null === $combine ) { return []; } $free = self::free( $tables, $held, $start, $end ); if ( count( $free ) < 2 ) { return []; } $picked = $combine( $free, $party ); $by_id = array_column( $free, null, 'id' ); $seats = 0; foreach ( $picked as $id ) { if ( ! isset( $by_id[ $id ] ) ) { return []; } $seats += $by_id[ $id ]['max_party']; } return count( $picked ) >= 2 && count( array_unique( $picked ) ) === count( $picked ) && $seats >= $party ? $picked : []; } /** * @param Table $table * @param Table $than */ private static function better( array $table, array $than ): bool { return [ $table['max_party'], $table['sort'], $table['id'] ] < [ $than['max_party'], $than['sort'], $than['id'] ]; } /** * Guests booked to arrive in the slot that starts at $start. * * @param list $arrivals */ private static function arriving( array $arrivals, int $start, int $interval ): int { $guests = 0; foreach ( $arrivals as $arrival ) { if ( $arrival['start'] >= $start && $arrival['start'] < $start + $interval ) { $guests += $arrival['party']; } } return $guests; } /** * A real calendar date as "YYYY-MM-DD": 2026-02-30 is not one. */ public static function is_date( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m ) && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ); } /** * "HH:MM" or "HH:MM:SS" as minutes from midnight. */ public static function minutes( string $time ): int { $parts = explode( ':', $time ); return (int) $parts[0] * 60 + (int) ( $parts[1] ?? 0 ); } /** * Minutes from midnight as "HH:MM". */ public static function clock( int $minutes ): string { return sprintf( '%02d:%02d', intdiv( $minutes, 60 ), $minutes % 60 ); } }