Files
breadcrumb-the-shire/lib/Service/Scheduler/ScheduleCalculator.php
fs 25370a1a55 add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00

174 lines
6.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace MintyPHP\Service\Scheduler;
class ScheduleCalculator
{
public static 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';
}
}
public static function normalizeScheduleType(?string $type): string
{
$type = strtolower(trim((string) $type));
return in_array($type, ['hourly', 'daily', 'weekly'], true) ? $type : 'daily';
}
public static function normalizeInterval(string $type, int $value): int
{
$type = self::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 static 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 static function normalizeWeekdaysCsv(mixed $value): ?string
{
$list = self::parseWeekdays($value);
if (!$list) {
return null;
}
return implode(',', $list);
}
/**
* @return array<int>
*/
public static 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 static function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable
{
$referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$type = self::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily'));
$interval = self::normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1));
$timezone = new \DateTimeZone(self::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 = self::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 = self::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;
}
}