parseDate((string) ($task['start_date'] ?? '')); if ($startDate === null) { return []; } $startDate = $startDate->setTime(0, 0, 0); if ($now < $startDate) { return []; } $frequencyType = (string) ($task['frequency_type'] ?? 'once'); if ($frequencyType !== 'recurring') { return [ 'cycle_key' => 'once', 'cycle_start_at' => $startDate, 'cycle_end_at' => $startDate->setTime(23, 59, 59), 'cycle_boundary_start_at' => $startDate, 'cycle_boundary_end_at' => $startDate->setTime(23, 59, 59), ]; } $unit = (string) ($task['recurrence_unit'] ?? ''); $interval = max(1, (int) ($task['recurrence_interval'] ?? 1)); if (!in_array($unit, ['day', 'week', 'month', 'year'], true)) { return []; } if ($unit === 'day') { return $this->resolveDayCycle($startDate, $now, $interval); } if ($unit === 'week') { return $this->resolveWeekCycle($startDate, $now, $interval); } if ($unit === 'month') { return $this->resolveMonthCycle($startDate, $now, $interval); } return $this->resolveYearCycle($startDate, $now, $interval); } public function resolveInitialCompletionCycle(array $task, DateTimeImmutable $assignedAt): array { $startDate = $this->parseDate((string) ($task['start_date'] ?? '')); if ($startDate === null) { return []; } $startDay = $startDate->setTime(0, 0, 0); $assignedDay = $assignedAt->setTime(0, 0, 0); $cycleStart = $assignedDay > $startDay ? $assignedDay : $startDay; return $this->resolveCompletionCycleFromStart($task, $cycleStart); } public function resolveNextCompletionCycle(array $task, array $lastAssignment): array { $lastCycleStart = $this->extractDateTime($lastAssignment['cycle_start_at'] ?? null); if ($lastCycleStart === null) { return []; } $completedAt = $this->extractDateTime($lastAssignment['completed_at'] ?? null); if ($completedAt === null) { return []; } $unit = (string) ($task['recurrence_unit'] ?? ''); $interval = max(1, (int) ($task['recurrence_interval'] ?? 1)); $plannedNextStart = $this->addInterval($lastCycleStart->setTime(0, 0, 0), $unit, $interval); if (!($plannedNextStart instanceof DateTimeImmutable)) { return []; } $completionAnchor = $completedAt->setTime(0, 0, 0); $cycleStart = $plannedNextStart > $completionAnchor ? $plannedNextStart : $completionAnchor; return $this->resolveCompletionCycleFromStart($task, $cycleStart); } public function resolveDueAt(array $task, array $cycle): ?string { $cycleStart = $this->extractDateTime($cycle['cycle_start_at'] ?? null); $cycleEnd = $this->extractDateTime($cycle['cycle_end_at'] ?? null); if ($cycleStart === null || $cycleEnd === null) { return null; } $ruleType = (string) ($task['deadline_rule_type'] ?? 'cycle_end'); if (!in_array($ruleType, ['cycle_end', 'offset_days', 'weekday', 'monthday'], true)) { $ruleType = 'cycle_end'; } $dueAt = null; if ($ruleType === 'cycle_end') { $dueAt = $cycleEnd; } elseif ($ruleType === 'offset_days') { $offsetDays = (int) ($task['deadline_offset_days'] ?? 0); if ($offsetDays <= 0) { return null; } $dueAt = $cycleStart ->modify('+' . $offsetDays . ' days') ->setTime(23, 59, 59); } elseif ($ruleType === 'weekday') { $weekday = (int) ($task['deadline_weekday'] ?? 0); if ($weekday < 1 || $weekday > 7) { return null; } $cycleEndDay = $cycleEnd->setTime(0, 0, 0); $endWeekday = (int) $cycleEndDay->format('N'); $diff = $endWeekday - $weekday; if ($diff < 0) { $diff += 7; } $dueAt = $cycleEndDay ->modify('-' . $diff . ' days') ->setTime(23, 59, 59); } elseif ($ruleType === 'monthday') { $monthDay = (int) ($task['deadline_monthday'] ?? 0); if ($monthDay < 1 || $monthDay > 31) { return null; } $year = (int) $cycleEnd->format('Y'); $month = (int) $cycleEnd->format('n'); $lastDay = (int) $cycleEnd->format('t'); $resolvedDay = min($monthDay, $lastDay); $dueAt = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', sprintf('%04d-%02d-%02d 23:59:59', $year, $month, $resolvedDay) ); if ($dueAt === false) { return null; } } if (!$dueAt instanceof DateTimeImmutable) { return null; } if ($dueAt < $cycleStart) { $dueAt = $cycleStart->setTime(23, 59, 59); } return $dueAt->format('Y-m-d H:i:s'); } private function resolveCompletionCycleFromStart(array $task, DateTimeImmutable $cycleStart): array { $frequencyType = (string) ($task['frequency_type'] ?? 'once'); if ($frequencyType !== 'recurring') { return []; } $unit = (string) ($task['recurrence_unit'] ?? ''); $interval = max(1, (int) ($task['recurrence_interval'] ?? 1)); if (!in_array($unit, ['day', 'week', 'month', 'year'], true)) { return []; } $normalizedStart = $cycleStart->setTime(0, 0, 0); $cycleEnd = $this->addInterval($normalizedStart, $unit, $interval); if (!($cycleEnd instanceof DateTimeImmutable)) { return []; } $cycleEnd = $cycleEnd->modify('-1 second'); return [ 'cycle_key' => 'roll-' . $normalizedStart->format('Ymd'), 'cycle_start_at' => $normalizedStart, 'cycle_end_at' => $cycleEnd, 'cycle_boundary_start_at' => $normalizedStart, 'cycle_boundary_end_at' => $cycleEnd, ]; } private function addInterval(DateTimeImmutable $start, string $unit, int $interval): ?DateTimeImmutable { if (!in_array($unit, ['day', 'week', 'month', 'year'], true)) { return null; } if ($interval < 1) { $interval = 1; } if ($unit === 'week') { return $start->modify('+' . ($interval * 7) . ' days'); } return $start->modify('+' . $interval . ' ' . $unit . ($interval === 1 ? '' : 's')); } private function resolveDayCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array { $currentDay = $now->setTime(0, 0, 0); $daysDiff = max(0, (int) $startDate->diff($currentDay)->format('%a')); $cycleIndex = (int) floor($daysDiff / $interval); $boundaryStart = $startDate->modify('+' . ($cycleIndex * $interval) . ' days'); $boundaryEnd = $boundaryStart->modify('+' . $interval . ' days')->modify('-1 second'); return $this->buildCycle( 'day-' . $boundaryStart->format('Ymd'), $startDate, $boundaryStart, $boundaryEnd ); } private function resolveWeekCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array { $anchorWeekStart = $this->weekStart($startDate); $currentWeekStart = $this->weekStart($now); $weeksDiff = max(0, intdiv((int) $anchorWeekStart->diff($currentWeekStart)->format('%a'), 7)); $cycleIndex = (int) floor($weeksDiff / $interval); $boundaryStart = $anchorWeekStart->modify('+' . ($cycleIndex * $interval * 7) . ' days'); $boundaryEnd = $boundaryStart->modify('+' . ($interval * 7) . ' days')->modify('-1 second'); return $this->buildCycle( 'week-' . $boundaryStart->format('Ymd'), $startDate, $boundaryStart, $boundaryEnd ); } private function resolveMonthCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array { $anchorMonthIndex = ((int) $startDate->format('Y') * 12) + ((int) $startDate->format('n') - 1); $currentMonthIndex = ((int) $now->format('Y') * 12) + ((int) $now->format('n') - 1); $monthDiff = max(0, $currentMonthIndex - $anchorMonthIndex); $cycleIndex = (int) floor($monthDiff / $interval); $cycleStartIndex = $anchorMonthIndex + ($cycleIndex * $interval); $year = (int) floor($cycleStartIndex / 12); $month = ($cycleStartIndex % 12) + 1; $boundaryStart = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', sprintf('%04d-%02d-01 00:00:00', $year, $month) ); if ($boundaryStart === false) { return []; } $boundaryEnd = $boundaryStart->modify('+' . $interval . ' months')->modify('-1 second'); return $this->buildCycle( 'month-' . $boundaryStart->format('Y-m'), $startDate, $boundaryStart, $boundaryEnd ); } private function resolveYearCycle(DateTimeImmutable $startDate, DateTimeImmutable $now, int $interval): array { $anchorYear = (int) $startDate->format('Y'); $currentYear = (int) $now->format('Y'); $yearDiff = max(0, $currentYear - $anchorYear); $cycleIndex = (int) floor($yearDiff / $interval); $cycleYear = $anchorYear + ($cycleIndex * $interval); $boundaryStart = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', sprintf('%04d-01-01 00:00:00', $cycleYear) ); if ($boundaryStart === false) { return []; } $boundaryEnd = $boundaryStart->modify('+' . $interval . ' years')->modify('-1 second'); return $this->buildCycle( 'year-' . $boundaryStart->format('Y'), $startDate, $boundaryStart, $boundaryEnd ); } private function buildCycle( string $cycleKey, DateTimeImmutable $startDate, DateTimeImmutable $boundaryStart, DateTimeImmutable $boundaryEnd ): array { $effectiveStart = $boundaryStart < $startDate ? $startDate : $boundaryStart; return [ 'cycle_key' => $cycleKey, 'cycle_start_at' => $effectiveStart, 'cycle_end_at' => $boundaryEnd, 'cycle_boundary_start_at' => $boundaryStart, 'cycle_boundary_end_at' => $boundaryEnd, ]; } private function weekStart(DateTimeImmutable $date): DateTimeImmutable { $normalized = $date->setTime(0, 0, 0); $weekday = (int) $normalized->format('N'); $offset = $weekday - 1; if ($offset <= 0) { return $normalized; } return $normalized->modify('-' . $offset . ' days'); } private function parseDate(string $value): ?DateTimeImmutable { if ($value === '') { return null; } try { return new DateTimeImmutable($value); } catch (Throwable $throwable) { return null; } } private function extractDateTime($value): ?DateTimeImmutable { if ($value instanceof DateTimeImmutable) { return $value; } if (!is_string($value) || trim($value) === '') { return null; } try { return new DateTimeImmutable($value); } catch (Throwable $throwable) { return null; } } }