Files
Lesstime/src/Service/RecurrenceCalculator.php
2026-03-19 18:10:35 +01:00

251 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Task;
use App\Entity\TaskRecurrence;
use App\Enum\RecurrenceType;
use DateTimeImmutable;
final class RecurrenceCalculator
{
public function getNextDate(Task $task): ?DateTimeImmutable
{
$recurrence = $task->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<string, int> */
private function getDayNumberMap(): array
{
return [
'monday' => 1,
'tuesday' => 2,
'wednesday' => 3,
'thursday' => 4,
'friday' => 5,
'saturday' => 6,
'sunday' => 7,
];
}
}