$weekly Weekly blocks for this agent. * @param list $overrides Date overrides for this agent. */ public function build( TimeZoneId $agent_timezone, DateTimeImmutable $range_start_utc, DateTimeImmutable $range_end_utc, array $weekly, array $overrides, ): IntervalSet { $zone = $agent_timezone->to_date_time_zone(); $local_start = $range_start_utc->setTimezone( $zone )->setTime( 0, 0, 0 ); $local_end = $range_end_utc->setTimezone( $zone )->setTime( 0, 0, 0 ); $collected = []; for ( $day = $local_start; $day <= $local_end; $day = $day->modify( '+1 day' ) ) { $date_local = $day->format( 'Y-m-d' ); $dow = (int) $day->format( 'w' ); $day_blocks = $this->resolve_day_blocks( $date_local, $dow, $weekly, $overrides ); foreach ( $day_blocks as [ $start_local, $end_local ] ) { $interval = $this->local_block_to_utc( $date_local, $start_local, $end_local, $zone ); if ( null !== $interval ) { $collected[] = $interval; } } } return IntervalSet::from( $collected ); } /** * Resolve local time blocks for a calendar day. * * @param string $date_local Local date Y-m-d. * @param int $dow Day of week (0-6, Sunday = 0). * @param list $weekly Weekly rows. * @param list $overrides Overrides. * * @return list Local HH:MM:SS pairs. */ private function resolve_day_blocks( string $date_local, int $dow, array $weekly, array $overrides ): array { foreach ( $overrides as $override ) { if ( $override->date_local()->format( 'Y-m-d' ) !== $date_local ) { continue; } if ( $override->is_closed() ) { return []; } $start = $override->start_local(); $end = $override->end_local(); if ( null === $start || null === $end ) { return []; } return [ [ $start, $end ] ]; } $blocks = []; foreach ( $weekly as $row ) { if ( $row->day_of_week() !== $dow ) { continue; } if ( ! $row->is_valid_on( $date_local ) ) { continue; } $blocks[] = [ $row->start_local(), $row->end_local() ]; } return $blocks; } /** * Convert both endpoints independently; clip spring-forward gaps. * * @param string $date_local Local date Y-m-d. * @param string $start_local Local start HH:MM[:SS]. * @param string $end_local Local end HH:MM[:SS]. * @param DateTimeZone $zone Agent timezone. */ private function local_block_to_utc( string $date_local, string $start_local, string $end_local, DateTimeZone $zone, ): ?Interval { $start_utc = $this->local_to_utc( $date_local, $start_local, $zone ); $end_utc = $this->local_to_utc( $date_local, $end_local, $zone ); if ( null === $start_utc ) { return null; } // Overnight: end_local <= start_local means next calendar day. if ( null === $end_utc || $end_utc <= $start_utc ) { $next_date = ( new DateTimeImmutable( $date_local, $zone ) )->modify( '+1 day' )->format( 'Y-m-d' ); $end_utc = $this->local_to_utc( $next_date, $end_local, $zone ); if ( null === $end_utc || $end_utc <= $start_utc ) { return null; } } return new Interval( $start_utc, $end_utc ); } /** * Map a local wall-clock to UTC, preferring the first occurrence on overlap days. * Returns null when the local time falls in a spring-forward gap. * * @param string $date_local Local date Y-m-d. * @param string $time_local Local time HH:MM[:SS]. * @param DateTimeZone $zone Agent timezone. */ private function local_to_utc( string $date_local, string $time_local, DateTimeZone $zone ): ?DateTimeImmutable { $time_local = strlen( $time_local ) === 5 ? $time_local . ':00' : $time_local; $raw = sprintf( '%s %s', $date_local, $time_local ); $dt = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $raw, $zone ); if ( false === $dt ) { return null; } // Gap detection: PHP may shift non-existent local times; reject those. if ( $dt->format( 'Y-m-d H:i:s' ) !== $raw ) { return null; } return $dt->setTimezone( new DateTimeZone( 'UTC' ) ); } }