start; } /** * Interval end (UTC, exclusive). */ public function end(): DateTimeImmutable { return $this->end; } /** * Half-open overlap: [a,b) overlaps [c,d) iff a < d && c < b. * * @param self $other Other interval. */ public function overlaps( self $other ): bool { return $this->start < $other->end && $other->start < $this->end; } /** * Whether this interval fully contains another (half-open). * * @param self $other Other interval. */ public function contains( self $other ): bool { return $this->start <= $other->start && $other->end <= $this->end; } /** * Whether the intervals touch at an endpoint without overlapping. * * @param self $other Other interval. */ public function abuts( self $other ): bool { return $this->end->getTimestamp() === $other->start->getTimestamp() || $other->end->getTimestamp() === $this->start->getTimestamp(); } /** * Duration in whole minutes. */ public function duration_minutes(): int { $seconds = $this->end->getTimestamp() - $this->start->getTimestamp(); return (int) intdiv( $seconds, 60 ); } /** * Shift both endpoints by a number of minutes. * * @param int $minutes Minutes to add (may be negative). */ public function shift( int $minutes ): self { $mod = sprintf( '%+d minutes', $minutes ); return new self( $this->start->modify( $mod ), $this->end->modify( $mod ) ); } /** * Expand by buffers before/after (minutes). Used for booking footprints. * * @param int $before_min Minutes before start. * @param int $after_min Minutes after end. */ public function expand( int $before_min, int $after_min ): self { $start = $before_min > 0 ? $this->start->modify( sprintf( '-%d minutes', $before_min ) ) : $this->start; $end = $after_min > 0 ? $this->end->modify( sprintf( '+%d minutes', $after_min ) ) : $this->end; return new self( $start, $end ); } /** * Assert an instant is UTC. * * @param DateTimeImmutable $instant Instant to check. * @param string $label Field label for errors. * * @throws DomainException When the instant is not in UTC. */ private static function assert_utc( DateTimeImmutable $instant, string $label ): void { $tz = $instant->getTimezone()->getName(); if ( 'UTC' !== $tz && '+00:00' !== $tz && 'Z' !== $tz && 'Etc/UTC' !== $tz ) { throw new DomainException( 'interval.not_utc', sprintf( 'Interval %s must be UTC, got %s.', htmlspecialchars( $label, ENT_QUOTES, 'UTF-8' ), htmlspecialchars( $tz, ENT_QUOTES, 'UTF-8' ) ) ); } } /** * Factory for UTC instants from unix timestamps. * * @param int $start_ts Start unix timestamp. * @param int $end_ts End unix timestamp. */ public static function from_timestamps( int $start_ts, int $end_ts ): self { $utc = new DateTimeZone( 'UTC' ); return new self( ( new DateTimeImmutable( '@' . $start_ts ) )->setTimezone( $utc ), ( new DateTimeImmutable( '@' . $end_ts ) )->setTimezone( $utc ) ); } }