*/ public const DURATION_CHOICES = [ 30, 60 ]; /** * Ceiling on a weekly series, matching the form's `max`. Enforced in the * repository too, so a hand-crafted POST cannot ask for ten thousand rows. */ public const MAX_WEEKLY_OCCURRENCES = 52; public function __construct( public readonly int $instructorId, public readonly string $startDt, public readonly string $endDt, public readonly int $durationMinutes = 60, public readonly ?int $offeringId = null, public readonly bool $isBooked = false, public readonly ?int $recurrenceGroup = null, public readonly ?int $id = null, ) {} /** * Normalise a submitted slot datetime to canonical `Y-m-d H:i:s`, or null when * it is not a real datetime. Accepts the HTML `datetime-local` form * (`Y-m-d\TH:i`, optionally with seconds) and the canonical form (optionally * without seconds). Anything else — including strings PHP would "helpfully" * coerce — is rejected so garbage never reaches the DATETIME column or throws * inside the weekly-series date arithmetic. */ public static function normalizeDateTime( string $value ): ?string { foreach ( [ 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i' ] as $format ) { $dt = \DateTimeImmutable::createFromFormat( '!' . $format, $value ); if ( false !== $dt && $dt->format( $format ) === $value ) { return $dt->format( 'Y-m-d H:i:s' ); } } return null; } /** * Split this window into consecutive lesson-length slots: 09:00–16:00 with * 60-minute lessons yields seven bookable slots. A trailing remainder shorter * than the lesson length is dropped, and an empty list is returned when the * window cannot fit a single lesson. * * @return list */ public function splitByDuration(): array { if ( $this->durationMinutes <= 0 ) { return []; } $end = new \DateTimeImmutable( $this->endDt ); $step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' ); $cursor = new \DateTimeImmutable( $this->startDt ); $chunkEnd = $cursor->add( $step ); $slots = []; while ( $chunkEnd <= $end ) { $slots[] = new self( instructorId: $this->instructorId, startDt: $cursor->format( 'Y-m-d H:i:s' ), endDt: $chunkEnd->format( 'Y-m-d H:i:s' ), durationMinutes: $this->durationMinutes, offeringId: $this->offeringId, ); $cursor = $chunkEnd; $chunkEnd = $cursor->add( $step ); } return $slots; } public static function fromRow( \stdClass $row ): self { return new self( instructorId: Val::int( $row->instructor_id ), startDt: Val::string( $row->start_dt ), endDt: Val::string( $row->end_dt ), durationMinutes: Val::int( $row->duration_minutes ), offeringId: Val::intOrNull( $row->offering_id ), isBooked: Val::bool( $row->is_booked ), recurrenceGroup: Val::intOrNull( $row->recurrence_group ), id: Val::int( $row->id ), ); } /** * Returns a plain array representation of the slot. * * @return array */ public function toArray(): array { return [ 'id' => $this->id, 'instructor_id' => $this->instructorId, 'offering_id' => $this->offeringId, 'start_dt' => $this->startDt, 'end_dt' => $this->endDt, 'duration_minutes' => $this->durationMinutes, 'is_booked' => $this->isBooked, 'recurrence_group' => $this->recurrenceGroup, ]; } }