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>
174 lines
6.2 KiB
PHP
174 lines
6.2 KiB
PHP
<?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;
|
||
}
|
||
}
|