diff --git a/docs/superpowers/plans/2026-03-19-zimbra-calendar.md b/docs/superpowers/plans/2026-03-19-zimbra-calendar.md index 53e6aa0..a76ad26 100644 --- a/docs/superpowers/plans/2026-03-19-zimbra-calendar.md +++ b/docs/superpowers/plans/2026-03-19-zimbra-calendar.md @@ -174,10 +174,13 @@ class TaskRecurrence private ?int $maxOccurrences = null; #[ORM\Column(type: 'integer', options: ['default' => 0])] - #[ORM\Version] #[Groups(['task_recurrence:read'])] private int $occurrenceCount = 0; + #[ORM\Version] + #[ORM\Column(type: 'integer', options: ['default' => 1])] + private int $version = 1; + /** @var Collection */ #[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')] private Collection $tasks; @@ -187,25 +190,9 @@ class TaskRecurrence $this->tasks = new ArrayCollection(); } - public function getId(): ?int { return $this->id; } - public function getType(): RecurrenceType { return $this->type; } - public function setType(RecurrenceType $type): static { $this->type = $type; return $this; } - public function getInterval(): int { return $this->interval; } - public function setInterval(int $interval): static { $this->interval = $interval; return $this; } - public function getDaysOfWeek(): ?array { return $this->daysOfWeek; } - public function setDaysOfWeek(?array $daysOfWeek): static { $this->daysOfWeek = $daysOfWeek; return $this; } - public function getDayOfMonth(): ?int { return $this->dayOfMonth; } - public function setDayOfMonth(?int $dayOfMonth): static { $this->dayOfMonth = $dayOfMonth; return $this; } - public function getWeekOfMonth(): ?int { return $this->weekOfMonth; } - public function setWeekOfMonth(?int $weekOfMonth): static { $this->weekOfMonth = $weekOfMonth; return $this; } - public function getEndDate(): ?\DateTimeImmutable { return $this->endDate; } - public function setEndDate(?\DateTimeImmutable $endDate): static { $this->endDate = $endDate; return $this; } - public function getMaxOccurrences(): ?int { return $this->maxOccurrences; } - public function setMaxOccurrences(?int $maxOccurrences): static { $this->maxOccurrences = $maxOccurrences; return $this; } - public function getOccurrenceCount(): int { return $this->occurrenceCount; } - public function incrementOccurrenceCount(): static { $this->occurrenceCount++; return $this; } - /** @return Collection */ - public function getTasks(): Collection { return $this->tasks; } + // Standard multi-line getters/setters following codebase convention (PHP CS Fixer). + // All properties get standard get/set methods. + // Special methods: incrementOccurrenceCount() and getVersion() } ``` @@ -722,38 +709,44 @@ final readonly class TaskCalendarProcessor implements ProcessorInterface } ``` -- [ ] **Step 2: Update Task entity** — change `Post` processor and `Patch`/`Delete` processors +- [ ] **Step 2: Update Task entity and TaskNumberProcessor** -The `Post` operation needs both task number generation AND calendar sync. Create a composite approach: -- `Post` processor: `TaskNumberProcessor` (which calls persistProcessor internally), then `CalDavService::syncTask()` is called by a wrapping processor, OR chain `TaskCalendarProcessor` to wrap `TaskNumberProcessor`. - -Simplest approach: modify `TaskNumberProcessor` to also handle calendar sync after persist, or create `TaskCalendarProcessor` as the outer processor that delegates to `TaskNumberProcessor` for Post. +**Approach chosen:** Keep `TaskNumberProcessor` for Post (inject `CalDavService` into it). Use `TaskCalendarProcessor` for Patch and Delete only. Update `Task.php` operations: ```php -new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), -new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), -new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), -``` - -For Post, `TaskCalendarProcessor` needs to delegate to `TaskNumberProcessor` instead of `persistProcessor`. Use a tagged service or inject `TaskNumberProcessor` directly for Post operations. - -Alternative (simpler): keep `TaskNumberProcessor` for Post, inject `CalDavService` into it, and call sync after the transaction. Use `TaskCalendarProcessor` only for Patch/Delete. - -```php -// In Task.php: new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), ``` -And add CalDAV sync to `TaskNumberProcessor::process()`: +Modify `TaskNumberProcessor`: inject `CalDavService`, after the transaction completes call sync: ```php -// After the persist, sync to calendar -$this->calDavService->syncTask($data); -$this->entityManager->flush(); +public function __construct( + // ... existing deps ... + private CalDavService $calDavService, +) {} + +public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed +{ + if ($operation instanceof Post && null !== $data->getProject()) { + $result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) { + $maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject()); + $data->setNumber($maxNumber + 1); + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + }); + + // After DB commit: sync to Zimbra + $this->calDavService->syncTask($data); + $this->entityManager->flush(); + + return $result; + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); +} ``` - [ ] **Step 3: Commit** @@ -772,9 +765,13 @@ git commit -m "feat : add TaskCalendarProcessor for CalDAV sync after DB operati - [ ] **Step 1: Add recurrence logic to Patch handling** +**CRITICAL: Detect status CHANGE to isFinal, not just current isFinal.** + In `TaskCalendarProcessor`, when processing a Patch: -1. After `persistProcessor->process()`, check if the task's status is `isFinal` AND task has a recurrence -2. If so: +1. **Before** calling `persistProcessor->process()`, store the original status: `$originalStatus = $data->getStatus()` +2. After `persistProcessor->process()`, check if the status **changed** to `isFinal` (original status was NOT isFinal, new status IS isFinal) AND task has a recurrence +3. If the status was already isFinal before the Patch, skip recurrence creation (avoids duplicates on subsequent edits) +4. If conditions met: - Archive the current task (`archived = true`) - Clear `calendarEventUid` and `calendarTodoUid` on archived task - Use `RecurrenceCalculator::getNextDate()` to compute next dates @@ -783,6 +780,7 @@ In `TaskCalendarProcessor`, when processing a Patch: - Generate new task number via `TaskRepository::findMaxNumberByProjectForUpdate()` - Set first status (lowest position, non-final) - Set recalculated scheduledStart, scheduledEnd, deadline + - **Do NOT copy** documents or bookStackLinks (those are specific to the completed task instance) - Copy `calendarEventUid` from the archived task (before clearing) to the new task - Create new VTODO for the new deadline - Increment `occurrenceCount` on `TaskRecurrence` @@ -812,6 +810,8 @@ git commit -m "feat : auto-create next recurring task when current task reaches - [ ] **Step 2: Update `UpdateTaskTool`** — same new params. After flush, call `$this->calDavService->syncTask($task)` and flush. Include calendar fields in JSON response. +- [ ] **Step 2b: Update `DeleteTaskTool`** — inject `CalDavService`. Before removing the task, store `calendarEventUid` and `calendarTodoUid`. After `$entityManager->flush()`, call `$this->calDavService->deleteEvent($eventUid)` and `$this->calDavService->deleteTodo($todoUid)` if non-null. **This is critical — MCP delete bypasses API Platform processors.** + - [ ] **Step 3: Create `CreateTaskRecurrenceTool`** ```php @@ -1245,15 +1245,16 @@ const form = reactive({ recurrenceEndDate: '' as string, }) -const weekDays = [ - { value: 'monday', label: 'Lu' }, - { value: 'tuesday', label: 'Ma' }, - { value: 'wednesday', label: 'Me' }, - { value: 'thursday', label: 'Je' }, - { value: 'friday', label: 'Ve' }, - { value: 'saturday', label: 'Sa' }, - { value: 'sunday', label: 'Di' }, -] +const { t } = useI18n() +const weekDays = computed(() => [ + { value: 'monday', label: t('tasks.planning.days.mon') }, + { value: 'tuesday', label: t('tasks.planning.days.tue') }, + { value: 'wednesday', label: t('tasks.planning.days.wed') }, + { value: 'thursday', label: t('tasks.planning.days.thu') }, + { value: 'friday', label: t('tasks.planning.days.fri') }, + { value: 'saturday', label: t('tasks.planning.days.sat') }, + { value: 'sunday', label: t('tasks.planning.days.sun') }, +]) function toggleDay(day: string) { const idx = form.recurrenceDaysOfWeek.indexOf(day) @@ -1336,6 +1337,24 @@ git commit -m "feat(ui) : add deadline badges and calendar/recurrence icons to t --- +## Task 15b: Update "Mes tâches" Page + +**Files:** +- Modify: `frontend/pages/my-tasks.vue` + +- [ ] **Step 1: Add deadline and scheduled date columns/badges** — same pattern as TaskListItem/TaskCard updates from Task 15. + +- [ ] **Step 2: Add sort options** — allow sorting by `deadline` and `scheduledStart` (use API Platform OrderFilter added in Task 5). Add sort dropdown or clickable column headers. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/my-tasks.vue +git commit -m "feat(ui) : add deadline/scheduled columns and sort options to Mes tâches page" +``` + +--- + ## Task 16: Create Admin Zimbra Tab **Files:**