Files
breadcrumb-the-shire/lib/Service/Scheduler/ScheduleCalculator.php

174 lines
6.2 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\Scheduler;
class ScheduleCalculator
{
2026-02-23 12:58:19 +01:00
public function normalizeTimezone(?string $timezone): string
{
$timezone = trim((string) $timezone);
if ($timezone === '') {
$timezone = defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC';
}
try {
new \DateTimeZone($timezone);
return $timezone;
} catch (\Throwable $exception) {
return defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC';
}
}
2026-02-23 12:58:19 +01:00
public function normalizeScheduleType(?string $type): string
{
$type = strtolower(trim((string) $type));
return in_array($type, ['hourly', 'daily', 'weekly'], true) ? $type : 'daily';
}
2026-02-23 12:58:19 +01:00
public function normalizeInterval(string $type, int $value): int
{
2026-02-23 12:58:19 +01:00
$type = $this->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));
}
2026-02-23 12:58:19 +01:00
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);
}
2026-02-23 12:58:19 +01:00
public function normalizeWeekdaysCsv(mixed $value): ?string
{
2026-02-23 12:58:19 +01:00
$list = $this->parseWeekdays($value);
if (!$list) {
return null;
}
return implode(',', $list);
}
/**
* @return array<int>
*/
2026-02-23 12:58:19 +01:00
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.
*/
2026-02-23 12:58:19 +01:00
public function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable
{
$referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
2026-02-23 12:58:19 +01:00
$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'));
}
2026-02-23 12:58:19 +01:00
$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.
2026-02-23 12:58:19 +01:00
$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;
}
}