normalizeScheduleType($type); if ($type === 'hourly') { return max(1, min(24, $value)); } if ($type === 'weekly') { return max(1, min(52, $value)); } return max(1, min(365, $value)); } public function normalizeTime(?string $time): ?string { $time = trim((string) $time); if ($time === '') { return null; } if (!preg_match('/^(\d{1,2}):(\d{2})$/', $time, $matches)) { return null; } $hour = (int) $matches[1]; $minute = (int) $matches[2]; if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) { return null; } return sprintf('%02d:%02d', $hour, $minute); } public function normalizeWeekdaysCsv(mixed $value): ?string { $list = $this->parseWeekdays($value); if (!$list) { return null; } return implode(',', $list); } /** * @return array */ public function parseWeekdays(mixed $value): array { $raw = []; if (is_array($value)) { $raw = $value; } else { $raw = explode(',', (string) $value); } $weekdays = []; foreach ($raw as $item) { $day = (int) trim((string) $item); if ($day >= 1 && $day <= 7) { $weekdays[$day] = true; } } $list = array_keys($weekdays); sort($list, SORT_NUMERIC); return $list; } /** * Calculates the next UTC execution time for a scheduled job based on its schedule * configuration and a reference point in time. * * Expected keys in $job: * - schedule_type (string) 'hourly'|'daily'|'weekly' * - schedule_interval (int) 1..24 for hourly, 1..365 for daily, 1..52 for weekly * - timezone (string) IANA timezone name (e.g. 'Europe/Berlin') * - schedule_time (string) 'HH:MM', required for daily and weekly * - schedule_weekdays_csv (string) CSV of ISO weekday numbers 1 (Mon)..7 (Sun), required for weekly * * All schedule calculations are performed in the job's local timezone to correctly * handle DST transitions. The returned value is always UTC. * * Returns null if the schedule is misconfigured (e.g. missing schedule_time for daily/weekly) * or if no valid next run could be found within the search window. */ public function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable { $referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $type = $this->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); $interval = $this->normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1)); $timezone = new \DateTimeZone($this->normalizeTimezone((string) ($job['timezone'] ?? ''))); $localReference = $referenceUtc->setTimezone($timezone); if ($type === 'hourly') { // Snap to the top of the current hour, then advance by the interval. $candidate = $localReference ->setTime((int) $localReference->format('H'), 0, 0) ->modify('+' . $interval . ' hour'); return $candidate->setTimezone(new \DateTimeZone('UTC')); } $time = $this->normalizeTime((string) ($job['schedule_time'] ?? '')); if ($time === null) { return null; } [$hours, $minutes] = array_map('intval', explode(':', $time)); if ($type === 'daily') { $candidate = $localReference->setTime($hours, $minutes, 0); if ($candidate <= $localReference) { $candidate = $candidate->modify('+' . $interval . ' day'); } return $candidate->setTimezone(new \DateTimeZone('UTC')); } // Weekly: iterate forward day by day until we find a matching weekday in the correct // week interval. The search window is 1110 days (~3 years), which comfortably covers // the maximum weekly interval of 52 weeks × 7 days/week = 364 days, with margin for // weekday alignment and DST edge cases. $weekdays = $this->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); if (!$weekdays) { $weekdays = [1]; } $startOfDay = $localReference->setTime(0, 0, 0); // 1970-01-05 is a Monday in any timezone offset that does not cross the date line, // used as a stable epoch to count week indices for "every N weeks" interval logic. $epochMonday = new \DateTimeImmutable('1970-01-05 00:00:00', $timezone); for ($i = 0; $i < 370 * 3; $i++) { $day = $startOfDay->modify('+' . $i . ' day'); $isoDay = (int) $day->format('N'); if (!in_array($isoDay, $weekdays, true)) { continue; } $daysFromEpoch = (int) $epochMonday->diff($day)->format('%r%a'); $weekIndex = (int) floor($daysFromEpoch / 7); if ($interval > 1 && ($weekIndex % $interval) !== 0) { continue; } $candidate = $day->setTime($hours, $minutes, 0); if ($candidate <= $localReference) { continue; } return $candidate->setTimezone(new \DateTimeZone('UTC')); } return null; } }