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:
@@ -14,4 +14,16 @@ class TaskStatusRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
parent::__construct($registry, TaskStatus::class);
|
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()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/State/RecurrenceHandler.php
Normal file
105
src/State/RecurrenceHandler.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user