getRecurrence(); $scheduledStart = $task->getScheduledStart(); if (null === $recurrence || null === $scheduledStart) { return null; } if ($this->hasReachedEnd($recurrence)) { return null; } $type = $recurrence->getType(); $interval = $recurrence->getInterval(); return match ($type) { RecurrenceType::Daily => $this->nextDaily($scheduledStart, $interval), RecurrenceType::Weekly => $this->nextWeekly($scheduledStart, $interval, $recurrence->getDaysOfWeek() ?? []), RecurrenceType::Monthly => $this->nextMonthly($scheduledStart, $interval, $recurrence), RecurrenceType::Yearly => $this->nextYearly($scheduledStart, $interval), default => null, }; } public function getNextEnd(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable { $scheduledStart = $task->getScheduledStart(); $scheduledEnd = $task->getScheduledEnd(); if (null === $scheduledEnd || null === $scheduledStart) { return null; } $duration = $scheduledStart->diff($scheduledEnd); return $nextStart->add($duration); } public function getNextDeadline(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable { $scheduledStart = $task->getScheduledStart(); $deadline = $task->getDeadline(); if (null === $deadline || null === $scheduledStart) { return null; } $offset = $scheduledStart->diff($deadline); return $nextStart->add($offset); } public function hasReachedEnd(TaskRecurrence $recurrence): bool { $maxOccurrences = $recurrence->getMaxOccurrences(); if (null !== $maxOccurrences && $recurrence->getOccurrenceCount() >= $maxOccurrences) { return true; } $endDate = $recurrence->getEndDate(); if (null !== $endDate) { $today = new DateTimeImmutable('today'); if ($endDate < $today) { return true; } } return false; } private function nextDaily(DateTimeImmutable $start, int $interval): DateTimeImmutable { return $start->modify(sprintf('+%d days', $interval)); } private function nextWeekly(DateTimeImmutable $start, int $interval, array $daysOfWeek): DateTimeImmutable { $candidate = $start->modify(sprintf('+%d weeks', $interval)); if ([] === $daysOfWeek) { return $candidate; } $dayNumberMap = $this->getDayNumberMap(); // Collect target day numbers $targetDayNumbers = []; foreach ($daysOfWeek as $day) { if (isset($dayNumberMap[$day])) { $targetDayNumbers[] = $dayNumberMap[$day]; } } if ([] === $targetDayNumbers) { return $candidate; } sort($targetDayNumbers); // Find the first matching day in the week starting from candidate $weekStart = (int) $candidate->format('N'); // 1=Mon, 7=Sun $candidateDayNum = $weekStart; foreach ($targetDayNumbers as $targetDay) { if ($targetDay >= $candidateDayNum) { $diff = $targetDay - $candidateDayNum; return $candidate->modify(sprintf('+%d days', $diff)); } } // Wrap to next week's first matching day $diff = 7 - $candidateDayNum + $targetDayNumbers[0]; return $candidate->modify(sprintf('+%d days', $diff)); } private function nextMonthly(DateTimeImmutable $start, int $interval, TaskRecurrence $recurrence): DateTimeImmutable { $dayOfMonth = $recurrence->getDayOfMonth(); $weekOfMonth = $recurrence->getWeekOfMonth(); $daysOfWeek = $recurrence->getDaysOfWeek() ?? []; if (null !== $dayOfMonth) { return $this->nextMonthlyByDayOfMonth($start, $interval, $dayOfMonth); } if (null !== $weekOfMonth && [] !== $daysOfWeek) { return $this->nextMonthlyByWeekOfMonth($start, $interval, $weekOfMonth, $daysOfWeek[0]); } // Fallback: same day of month, interval months ahead return $this->nextMonthlyByDayOfMonth($start, $interval, (int) $start->format('j')); } private function nextMonthlyByDayOfMonth(DateTimeImmutable $start, int $interval, int $dayOfMonth): DateTimeImmutable { $year = (int) $start->format('Y'); $month = (int) $start->format('n'); $month += $interval; while ($month > 12) { $month -= 12; ++$year; } // Handle month overflow (e.g. dayOfMonth=31 in a 30-day month) $daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t'); $day = min($dayOfMonth, $daysInMonth); return new DateTimeImmutable(sprintf( '%d-%02d-%02d %s', $year, $month, $day, $start->format('H:i:s'), )); } private function nextMonthlyByWeekOfMonth(DateTimeImmutable $start, int $interval, int $weekOfMonth, string $dayName): DateTimeImmutable { $year = (int) $start->format('Y'); $month = (int) $start->format('n'); $month += $interval; while ($month > 12) { $month -= 12; ++$year; } $dayNumberMap = $this->getDayNumberMap(); $targetDayNum = $dayNumberMap[$dayName] ?? 1; // Find the Nth occurrence of the target weekday in the target month $firstOfMonth = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month)); $firstDayNum = (int) $firstOfMonth->format('N'); // 1=Mon, 7=Sun // Days until first occurrence of target weekday $daysToFirst = ($targetDayNum - $firstDayNum + 7) % 7; $dayOfMonth = 1 + $daysToFirst + ($weekOfMonth - 1) * 7; // Handle overflow (e.g. 5th occurrence that doesn't exist) $daysInMonth = (int) $firstOfMonth->format('t'); if ($dayOfMonth > $daysInMonth) { // Fall back to last occurrence $dayOfMonth -= 7; } return new DateTimeImmutable(sprintf( '%d-%02d-%02d %s', $year, $month, $dayOfMonth, $start->format('H:i:s'), )); } private function nextYearly(DateTimeImmutable $start, int $interval): DateTimeImmutable { $year = (int) $start->format('Y') + $interval; $month = (int) $start->format('n'); $day = (int) $start->format('j'); // Handle leap year: Feb 29 → Feb 28 $daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t'); $day = min($day, $daysInMonth); return new DateTimeImmutable(sprintf( '%d-%02d-%02d %s', $year, $month, $day, $start->format('H:i:s'), )); } /** @return array */ private function getDayNumberMap(): array { return [ 'monday' => 1, 'tuesday' => 2, 'wednesday' => 3, 'thursday' => 4, 'friday' => 5, 'saturday' => 6, 'sunday' => 7, ]; } }