1
0
Files
breadcrumb-the-shire/core/Service/Scheduler/ScheduleCalculator.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

174 lines
6.2 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 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 function normalizeScheduleType(?string $type): string
{
$type = strtolower(trim((string) $type));
return in_array($type, ['hourly', 'daily', 'weekly'], true) ? $type : 'daily';
}
public function normalizeInterval(string $type, int $value): int
{
$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));
}
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<int>
*/
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;
}
}