diff --git a/src/Service/RecurrenceCalculator.php b/src/Service/RecurrenceCalculator.php new file mode 100644 index 0000000..f501a0b --- /dev/null +++ b/src/Service/RecurrenceCalculator.php @@ -0,0 +1,250 @@ +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, + ]; + } +}