feat : add RecurrenceCalculator service for next occurrence dates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
250
src/Service/RecurrenceCalculator.php
Normal file
250
src/Service/RecurrenceCalculator.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user