feat : add RecurrenceHandler for auto-creating next recurring task

When a task transitions to a final status, archives the current task and creates
a new occurrence with recalculated dates. Adds TaskStatusRepository::findFirstNonFinal()
to assign the initial status to the new task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-19 10:19:25 +01:00
parent 5a47adace5
commit b3d317284e
2 changed files with 117 additions and 0 deletions

View File

@@ -14,4 +14,16 @@ class TaskStatusRepository extends ServiceEntityRepository
{
parent::__construct($registry, TaskStatus::class);
}
public function findFirstNonFinal(): ?TaskStatus
{
return $this->createQueryBuilder('s')
->where('s.isFinal = :final')
->setParameter('final', false)
->orderBy('s.position', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\State;
use App\Entity\Task;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Service\CalDavService;
use App\Service\RecurrenceCalculator;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RecurrenceHandler
{
public function __construct(
private RecurrenceCalculator $calculator,
private TaskRepository $taskRepository,
private TaskStatusRepository $statusRepository,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
) {}
public function handleIfNeeded(Task $task, bool $wasAlreadyFinal): void
{
// Only trigger on STATUS CHANGE to isFinal
$currentStatus = $task->getStatus();
$isNowFinal = $currentStatus?->isFinal() ?? false;
if (!$isNowFinal || $wasAlreadyFinal) {
return; // No transition to final
}
$recurrence = $task->getRecurrence();
if (null === $recurrence) {
return; // Not a recurring task
}
if ($this->calculator->hasReachedEnd($recurrence)) {
return; // Recurrence is done
}
$nextStart = $this->calculator->getNextDate($task);
if (null === $nextStart) {
return;
}
// Archive current task, clear calendar UIDs
$savedEventUid = $task->getCalendarEventUid();
$task->setArchived(true);
$task->setCalendarEventUid(null);
$task->setCalendarTodoUid(null);
// Create new task with same fields
$newTask = new Task();
$newTask->setProject($task->getProject());
$newTask->setTitle($task->getTitle());
$newTask->setDescription($task->getDescription());
$newTask->setAssignee($task->getAssignee());
$newTask->setEffort($task->getEffort());
$newTask->setPriority($task->getPriority());
$newTask->setGroup($task->getGroup());
$newTask->setRecurrence($recurrence);
$newTask->setSyncToCalendar($task->isSyncToCalendar());
// Copy tags
foreach ($task->getTags() as $tag) {
$newTask->addTag($tag);
}
// Set first non-final status
$firstStatus = $this->statusRepository->findFirstNonFinal();
$newTask->setStatus($firstStatus);
// Set recalculated dates
$newTask->setScheduledStart($nextStart);
$newTask->setScheduledEnd($this->calculator->getNextEnd($task, $nextStart));
$newTask->setDeadline($this->calculator->getNextDeadline($task, $nextStart));
// Copy calendar event UID (recurring VEVENT is shared)
$newTask->setCalendarEventUid($savedEventUid);
// Generate task number in transaction
$this->entityManager->wrapInTransaction(function () use ($newTask): void {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($newTask->getProject());
$newTask->setNumber($maxNumber + 1);
$this->entityManager->persist($newTask);
$this->entityManager->flush();
});
// Increment occurrence count (with optimistic locking via @Version)
$recurrence->incrementOccurrenceCount();
$this->entityManager->flush();
// Sync new task's VTODO (new deadline) to Zimbra
if ($newTask->isSyncToCalendar() && $newTask->getDeadline()) {
$uid = $this->calDavService->createTodo($newTask);
if ($uid) {
$newTask->setCalendarTodoUid($uid);
$newTask->setCalendarSyncError(null);
$this->entityManager->flush();
}
}
}
}