251 lines
7.6 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|