From 30fb36e6682ca81783cb9dab05e3c5caace71761 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 19 Mar 2026 09:58:17 +0100 Subject: [PATCH] docs : add Zimbra CalDAV calendar integration implementation plan 20 tasks covering entities, services, API resources, MCP tools, frontend components, i18n, fixtures, and testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-19-zimbra-calendar.md | 1446 +++++++++++++++++ 1 file changed, 1446 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-19-zimbra-calendar.md diff --git a/docs/superpowers/plans/2026-03-19-zimbra-calendar.md b/docs/superpowers/plans/2026-03-19-zimbra-calendar.md new file mode 100644 index 0000000..53e6aa0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-zimbra-calendar.md @@ -0,0 +1,1446 @@ +# Zimbra CalDAV Calendar Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Sync Lesstime tasks to a Zimbra OVH calendar via CalDAV, with support for scheduled dates, deadlines, recurring tasks, and one-way push sync. + +**Architecture:** New entities (`TaskRecurrence`, `ZimbraConfiguration`) + new fields on `Task` for dates and calendar UIDs. A `CalDavService` handles HTTP CalDAV calls. Sync is triggered by an API Platform `TaskCalendarProcessor` (after DB flush) and explicitly in MCP tools. Frontend adds a "Planification" tab in TaskModal with date pickers, calendar toggle, and recurrence form. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, `sabre/vobject` (ICS generation), Symfony HttpClient (CalDAV requests), Nuxt 4, Vue 3, Tailwind CSS. + +**Spec:** `docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md` + +--- + +## Task 1: Install PHP dependency `sabre/vobject` + +**Files:** +- Modify: `composer.json` + +- [ ] **Step 1: Install sabre/vobject** + +```bash +docker exec -t php-lesstime-fpm composer require sabre/vobject +``` + +- [ ] **Step 2: Verify installation** + +```bash +docker exec -t php-lesstime-fpm composer show sabre/vobject +``` + +Expected: shows sabre/vobject version info. + +- [ ] **Step 3: Commit** + +```bash +git add composer.json composer.lock +git commit -m "chore : add sabre/vobject for CalDAV ICS generation" +``` + +--- + +## Task 2: Create `RecurrenceType` PHP Enum + +**Files:** +- Create: `src/Enum/RecurrenceType.php` + +- [ ] **Step 1: Create the enum** + +```php + + */ +class TaskRecurrenceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TaskRecurrence::class); + } +} +``` + +- [ ] **Step 2: Create the entity** + +```php + ['task_recurrence:read']], + denormalizationContext: ['groups' => ['task_recurrence:write']], +)] +#[ORM\Entity(repositoryClass: TaskRecurrenceRepository::class)] +class TaskRecurrence +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['task_recurrence:read', 'task:read'])] + private ?int $id = null; + + #[ORM\Column(type: 'string', enumType: RecurrenceType::class)] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private RecurrenceType $type = RecurrenceType::Daily; + + #[ORM\Column(type: 'integer', options: ['default' => 1])] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private int $interval = 1; + + #[ORM\Column(type: 'json', nullable: true)] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private ?array $daysOfWeek = null; + + #[ORM\Column(type: 'integer', nullable: true)] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private ?int $dayOfMonth = null; + + #[ORM\Column(type: 'integer', nullable: true)] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private ?int $weekOfMonth = null; + + #[ORM\Column(type: 'date_immutable', nullable: true)] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private ?\DateTimeImmutable $endDate = null; + + #[ORM\Column(type: 'integer', nullable: true)] + #[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])] + private ?int $maxOccurrences = null; + + #[ORM\Column(type: 'integer', options: ['default' => 0])] + #[ORM\Version] + #[Groups(['task_recurrence:read'])] + private int $occurrenceCount = 0; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')] + private Collection $tasks; + + public function __construct() + { + $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; } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/TaskRecurrence.php src/Repository/TaskRecurrenceRepository.php +git commit -m "feat : add TaskRecurrence entity with RecurrenceType enum" +``` + +--- + +## Task 4: Create `ZimbraConfiguration` Entity + +**Files:** +- Create: `src/Entity/ZimbraConfiguration.php` +- Create: `src/Repository/ZimbraConfigurationRepository.php` + +- [ ] **Step 1: Create the repository** + +```php + + */ +class ZimbraConfigurationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ZimbraConfiguration::class); + } + + public function findSingleton(): ?ZimbraConfiguration + { + return $this->createQueryBuilder('z') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} +``` + +- [ ] **Step 2: Create the entity** (follows `GiteaConfiguration` pattern) + +```php + false])] + private bool $enabled = false; + + public function getId(): ?int { return $this->id; } + public function getServerUrl(): ?string { return $this->serverUrl; } + public function setServerUrl(?string $serverUrl): static { $this->serverUrl = $serverUrl; return $this; } + public function getUsername(): ?string { return $this->username; } + public function setUsername(?string $username): static { $this->username = $username; return $this; } + public function getEncryptedPassword(): ?string { return $this->encryptedPassword; } + public function setEncryptedPassword(?string $encryptedPassword): static { $this->encryptedPassword = $encryptedPassword; return $this; } + public function hasPassword(): bool { return null !== $this->encryptedPassword; } + public function getCalendarPath(): ?string { return $this->calendarPath; } + public function setCalendarPath(?string $calendarPath): static { $this->calendarPath = $calendarPath; return $this; } + public function isEnabled(): bool { return $this->enabled; } + public function setEnabled(bool $enabled): static { $this->enabled = $enabled; return $this; } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/ZimbraConfiguration.php src/Repository/ZimbraConfigurationRepository.php +git commit -m "feat : add ZimbraConfiguration entity for CalDAV settings" +``` + +--- + +## Task 5: Extend `Task` Entity with Calendar Fields + +**Files:** +- Modify: `src/Entity/Task.php` + +- [ ] **Step 1: Add new properties and imports** + +Add to `Task.php`: +- `use ApiPlatform\Doctrine\Orm\Filter\DateFilter;` +- `use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;` +- `use Symfony\Component\Validator\Constraints as Assert;` +- `use Symfony\Component\Validator\Context\ExecutionContextInterface;` +- `#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]` +- `#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]` +- `#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]` + +New properties (after `$clientTicket`): + +```php +#[ORM\Column(type: 'datetime_immutable', nullable: true)] +#[Groups(['task:read', 'task:write'])] +private ?\DateTimeImmutable $scheduledStart = null; + +#[ORM\Column(type: 'datetime_immutable', nullable: true)] +#[Groups(['task:read', 'task:write'])] +private ?\DateTimeImmutable $scheduledEnd = null; + +#[ORM\Column(type: 'datetime_immutable', nullable: true)] +#[Groups(['task:read', 'task:write'])] +private ?\DateTimeImmutable $deadline = null; + +#[ORM\Column(type: 'boolean', options: ['default' => false])] +#[Groups(['task:read', 'task:write'])] +private bool $syncToCalendar = false; + +#[ORM\Column(length: 255, nullable: true)] +private ?string $calendarEventUid = null; + +#[ORM\Column(length: 255, nullable: true)] +private ?string $calendarTodoUid = null; + +#[ORM\Column(type: 'text', nullable: true)] +#[Groups(['task:read'])] +private ?string $calendarSyncError = null; + +#[ORM\ManyToOne(targetEntity: TaskRecurrence::class, inversedBy: 'tasks')] +#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] +#[Groups(['task:read', 'task:write'])] +private ?TaskRecurrence $recurrence = null; +``` + +- [ ] **Step 2: Add getters/setters** + +```php +public function getScheduledStart(): ?\DateTimeImmutable { return $this->scheduledStart; } +public function setScheduledStart(?\DateTimeImmutable $scheduledStart): static { $this->scheduledStart = $scheduledStart; return $this; } +public function getScheduledEnd(): ?\DateTimeImmutable { return $this->scheduledEnd; } +public function setScheduledEnd(?\DateTimeImmutable $scheduledEnd): static { $this->scheduledEnd = $scheduledEnd; return $this; } +public function getDeadline(): ?\DateTimeImmutable { return $this->deadline; } +public function setDeadline(?\DateTimeImmutable $deadline): static { $this->deadline = $deadline; return $this; } +public function isSyncToCalendar(): bool { return $this->syncToCalendar; } +public function setSyncToCalendar(bool $syncToCalendar): static { $this->syncToCalendar = $syncToCalendar; return $this; } +public function getCalendarEventUid(): ?string { return $this->calendarEventUid; } +public function setCalendarEventUid(?string $calendarEventUid): static { $this->calendarEventUid = $calendarEventUid; return $this; } +public function getCalendarTodoUid(): ?string { return $this->calendarTodoUid; } +public function setCalendarTodoUid(?string $calendarTodoUid): static { $this->calendarTodoUid = $calendarTodoUid; return $this; } +public function getCalendarSyncError(): ?string { return $this->calendarSyncError; } +public function setCalendarSyncError(?string $calendarSyncError): static { $this->calendarSyncError = $calendarSyncError; return $this; } +public function getRecurrence(): ?TaskRecurrence { return $this->recurrence; } +public function setRecurrence(?TaskRecurrence $recurrence): static { $this->recurrence = $recurrence; return $this; } +``` + +- [ ] **Step 3: Add validation callback** + +```php +#[Assert\Callback] +public function validateScheduledDates(ExecutionContextInterface $context): void +{ + if (($this->scheduledStart === null) !== ($this->scheduledEnd === null)) { + $context->buildViolation('scheduledStart and scheduledEnd must both be set or both be null.') + ->atPath('scheduledEnd') + ->addViolation(); + } + if ($this->scheduledStart !== null && $this->scheduledEnd !== null + && $this->scheduledEnd <= $this->scheduledStart) { + $context->buildViolation('scheduledEnd must be after scheduledStart.') + ->atPath('scheduledEnd') + ->addViolation(); + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Entity/Task.php +git commit -m "feat : add calendar fields to Task entity (dates, sync, recurrence)" +``` + +--- + +## Task 6: Generate and Run Migration + +**Files:** +- Create: `migrations/Version*.php` (auto-generated) + +- [ ] **Step 1: Generate migration** + +```bash +docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff +``` + +- [ ] **Step 2: Review migration** — verify it creates `task_recurrence` table, `zimbra_configuration` table, and adds new columns to `task`. + +- [ ] **Step 3: Run migration** + +```bash +make migration-migrate +``` + +- [ ] **Step 4: Commit** + +```bash +git add migrations/ +git commit -m "feat : migration for TaskRecurrence, ZimbraConfiguration, and Task calendar fields" +``` + +--- + +## Task 7: Create `CalDavService` + +**Files:** +- Create: `src/Service/CalDavService.php` + +- [ ] **Step 1: Create the service** + +The service handles all CalDAV HTTP communication with Zimbra. Key points: +- Uses `Symfony\Contracts\HttpClient\HttpClientInterface` for HTTP requests +- Uses `Sabre\VObject\Component\VCalendar` for ICS generation +- Uses `TokenEncryptor` to decrypt the stored password +- All dates in UTC with `Z` suffix +- 5 second timeout on all requests +- VEVENT for scheduled time slots (with RRULE if recurring) +- VTODO for deadlines +- HTTP Basic Auth +- PUT to create/update, DELETE to remove + +Methods: +- `isConfigured(): bool` — checks ZimbraConfiguration exists and is enabled +- `testConnection(): bool` — PROPFIND on calendar path +- `createEvent(Task): ?string` — creates VEVENT, returns UID +- `createTodo(Task): ?string` — creates VTODO, returns UID +- `updateEvent(Task): bool` — updates VEVENT by existing UID +- `updateTodo(Task): bool` — updates VTODO by existing UID +- `deleteEvent(?string $uid): bool` — DELETE by UID, safe with null +- `deleteTodo(?string $uid): bool` — DELETE by UID, safe with null +- `syncTask(Task): void` — orchestrates create/update/delete logic based on task state, updates `calendarSyncError` +- Private: `buildEventCalendar(Task): VCalendar`, `buildTodoCalendar(Task): VCalendar`, `buildRRule(TaskRecurrence): string`, `makeRequest(string $method, string $url, ?string $body): bool` + +SUMMARY format: `[PROJECT_CODE-NUMBER] Task Title` +DESCRIPTION: task description + `\n\nLesstime task` + +RRULE mapping from `TaskRecurrence`: +- `Daily` → `FREQ=DAILY;INTERVAL={interval}` +- `Weekly` → `FREQ=WEEKLY;INTERVAL={interval};BYDAY=MO,WE` (from daysOfWeek) +- `Monthly` with dayOfMonth → `FREQ=MONTHLY;INTERVAL={interval};BYMONTHDAY={dayOfMonth}` +- `Monthly` with weekOfMonth → `FREQ=MONTHLY;INTERVAL={interval};BYDAY={weekOfMonth}MO` (e.g. `2MO` for 2nd Monday) +- `Yearly` → `FREQ=YEARLY;INTERVAL={interval}` +- Append `;UNTIL={endDate}` or `;COUNT={maxOccurrences}` if set + +Day name mapping for BYDAY: `monday→MO`, `tuesday→TU`, `wednesday→WE`, `thursday→TH`, `friday→FR`, `saturday→SA`, `sunday→SU` + +- [ ] **Step 2: Commit** + +```bash +git add src/Service/CalDavService.php +git commit -m "feat : add CalDavService for Zimbra CalDAV sync" +``` + +--- + +## Task 8: Create `RecurrenceCalculator` Service + +**Files:** +- Create: `src/Service/RecurrenceCalculator.php` + +- [ ] **Step 1: Create the service** + +Methods: +- `getNextDate(Task): ?\DateTimeImmutable` — calculates next scheduledStart based on current task's scheduledStart and recurrence pattern. Returns null if max occurrences/end date reached. +- `hasReachedEnd(TaskRecurrence): bool` — checks if maxOccurrences or endDate is reached + +Calculation logic (from current task's `scheduledStart`, NOT from completion date): +- **Daily**: `scheduledStart + interval days` +- **Weekly**: find next matching day from `daysOfWeek`, `interval` weeks ahead from current week +- **Monthly** with dayOfMonth: same day, `interval` months ahead +- **Monthly** with weekOfMonth: same Nth weekday, `interval` months ahead +- **Yearly**: same date, `interval` years ahead + +Duration preservation: `scheduledEnd - scheduledStart` is reapplied to the new start date. + +Deadline recalculation: if the task has a deadline, advance it by the same offset (`deadline - scheduledStart`) from the new start date. + +- [ ] **Step 2: Commit** + +```bash +git add src/Service/RecurrenceCalculator.php +git commit -m "feat : add RecurrenceCalculator service for next occurrence dates" +``` + +--- + +## Task 9: Create Zimbra Settings API (Admin Config) + +**Files:** +- Create: `src/ApiResource/ZimbraSettings.php` +- Create: `src/State/ZimbraSettingsProvider.php` +- Create: `src/State/ZimbraSettingsProcessor.php` +- Create: `src/ApiResource/ZimbraTestConnection.php` +- Create: `src/State/ZimbraTestConnectionProvider.php` + +- [ ] **Step 1: Create `ZimbraSettings` API Resource** (follow `GiteaSettings` pattern exactly) + +```php + ['zimbra_settings:read']], + provider: ZimbraSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Put( + uriTemplate: '/settings/zimbra', + denormalizationContext: ['groups' => ['zimbra_settings:write']], + normalizationContext: ['groups' => ['zimbra_settings:read']], + provider: ZimbraSettingsProvider::class, + processor: ZimbraSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class ZimbraSettings +{ + #[Groups(['zimbra_settings:read', 'zimbra_settings:write'])] + public ?string $serverUrl = null; + + #[Groups(['zimbra_settings:read', 'zimbra_settings:write'])] + public ?string $username = null; + + #[Groups(['zimbra_settings:read', 'zimbra_settings:write'])] + public ?string $calendarPath = null; + + #[Groups(['zimbra_settings:write'])] + public ?string $password = null; + + #[Groups(['zimbra_settings:read', 'zimbra_settings:write'])] + public bool $enabled = false; + + #[Groups(['zimbra_settings:read'])] + public bool $hasPassword = false; +} +``` + +- [ ] **Step 2: Create Provider** — reads `ZimbraConfiguration` singleton, maps to DTO +- [ ] **Step 3: Create Processor** — persists settings, uses `TokenEncryptor::encrypt()` for password (only if non-empty), follows `GiteaSettingsProcessor` pattern +- [ ] **Step 4: Create `ZimbraTestConnection` API Resource** (follow `GiteaTestConnection` pattern) + +```php + ['zimbra_test:read']], + provider: ZimbraTestConnectionProvider::class, + processor: ZimbraTestConnectionProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class ZimbraTestConnection +{ + #[Groups(['zimbra_test:read'])] + public bool $success = false; +} +``` + +- [ ] **Step 5: Create `ZimbraTestConnectionProvider`** — calls `CalDavService::testConnection()`, returns result +- [ ] **Step 6: Commit** + +```bash +git add src/ApiResource/ZimbraSettings.php src/ApiResource/ZimbraTestConnection.php src/State/ZimbraSettings*.php src/State/ZimbraTestConnectionProvider.php +git commit -m "feat : add Zimbra settings API (CRUD + test connection)" +``` + +--- + +## Task 10: Create `TaskCalendarProcessor` (API Platform Sync) + +**Files:** +- Create: `src/State/TaskCalendarProcessor.php` +- Modify: `src/Entity/Task.php` (update processor references) + +- [ ] **Step 1: Create the processor** + +This processor decorates the persist/remove processors. It: +1. Calls the inner processor first (DB flush happens) +2. Then calls `CalDavService::syncTask()` (after DB is committed) +3. For Delete: stores UIDs before deletion, then deletes from Zimbra after DB removal + +```php + + */ +final readonly class TaskCalendarProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $persistProcessor + * @param ProcessorInterface $removeProcessor + */ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private ProcessorInterface $removeProcessor, + private CalDavService $calDavService, + private EntityManagerInterface $entityManager, + ) {} + + /** + * @param Task $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ($operation instanceof Delete) { + $eventUid = $data->getCalendarEventUid(); + $todoUid = $data->getCalendarTodoUid(); + + $result = $this->removeProcessor->process($data, $operation, $uriVariables, $context); + + if ($eventUid) { + $this->calDavService->deleteEvent($eventUid); + } + if ($todoUid) { + $this->calDavService->deleteTodo($todoUid); + } + + return $result; + } + + // For Post: wrap in transaction for task number (delegates to TaskNumberProcessor logic) + // For Patch: normal persist + $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context); + + // After DB flush: sync to Zimbra + $this->calDavService->syncTask($data); + $this->entityManager->flush(); // persist any UID/error changes + + return $result; + } +} +``` + +- [ ] **Step 2: Update Task entity** — change `Post` processor and `Patch`/`Delete` processors + +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. + +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()`: + +```php +// After the persist, sync to calendar +$this->calDavService->syncTask($data); +$this->entityManager->flush(); +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/State/TaskCalendarProcessor.php src/State/TaskNumberProcessor.php src/Entity/Task.php +git commit -m "feat : add TaskCalendarProcessor for CalDAV sync after DB operations" +``` + +--- + +## Task 11: Recurrence Auto-Creation on Task Completion + +**Files:** +- Modify: `src/State/TaskCalendarProcessor.php` + +- [ ] **Step 1: Add recurrence logic to Patch handling** + +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: + - Archive the current task (`archived = true`) + - Clear `calendarEventUid` and `calendarTodoUid` on archived task + - Use `RecurrenceCalculator::getNextDate()` to compute next dates + - If next date is null (recurrence ended), stop + - Create a new Task with same fields (title, description, assignee, tags, project, group, effort, priority, syncToCalendar, recurrence) + - Generate new task number via `TaskRepository::findMaxNumberByProjectForUpdate()` + - Set first status (lowest position, non-final) + - Set recalculated scheduledStart, scheduledEnd, deadline + - Copy `calendarEventUid` from the archived task (before clearing) to the new task + - Create new VTODO for the new deadline + - Increment `occurrenceCount` on `TaskRecurrence` + - Flush all changes + +- [ ] **Step 2: Inject dependencies** — add `RecurrenceCalculator`, `TaskRepository`, `TaskStatusRepository` + +- [ ] **Step 3: Commit** + +```bash +git add src/State/TaskCalendarProcessor.php +git commit -m "feat : auto-create next recurring task when current task reaches final status" +``` + +--- + +## Task 12: Update MCP Tools + +**Files:** +- Modify: `src/Mcp/Tool/Task/CreateTaskTool.php` +- Modify: `src/Mcp/Tool/Task/UpdateTaskTool.php` +- Create: `src/Mcp/Tool/Task/CreateTaskRecurrenceTool.php` +- Create: `src/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php` +- Create: `src/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php` + +- [ ] **Step 1: Update `CreateTaskTool`** — add optional params `?string $scheduledStart`, `?string $scheduledEnd`, `?string $deadline`, `?bool $syncToCalendar`. After the transaction, call `$this->calDavService->syncTask($task)` and flush. Add `CalDavService` to constructor. + +- [ ] **Step 2: Update `UpdateTaskTool`** — same new params. After flush, call `$this->calDavService->syncTask($task)` and flush. Include calendar fields in JSON response. + +- [ ] **Step 3: Create `CreateTaskRecurrenceTool`** + +```php +#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array. For monthly, provide dayOfMonth OR weekOfMonth.')] +class CreateTaskRecurrenceTool +{ + // Constructor: EntityManagerInterface, TaskRepository, CalDavService + + public function __invoke( + int $taskId, + string $type, + int $interval = 1, + ?array $daysOfWeek = null, + ?int $dayOfMonth = null, + ?int $weekOfMonth = null, + ?string $endDate = null, + ?int $maxOccurrences = null, + ): string { + // Find task, create TaskRecurrence, set on task, flush + // Sync to calendar (updates RRULE on existing VEVENT) + // Return JSON response + } +} +``` + +- [ ] **Step 4: Create `UpdateTaskRecurrenceTool`** and `DeleteTaskRecurrenceTool`** — similar patterns + +- [ ] **Step 5: Commit** + +```bash +git add src/Mcp/Tool/Task/ +git commit -m "feat : update MCP tools with calendar fields and add recurrence tools" +``` + +--- + +## Task 13: Frontend DTOs and Services + +**Files:** +- Modify: `frontend/services/dto/task.ts` +- Create: `frontend/services/dto/task-recurrence.ts` +- Create: `frontend/services/dto/zimbra.ts` +- Create: `frontend/services/zimbra.ts` +- Create: `frontend/services/task-recurrences.ts` + +- [ ] **Step 1: Update `Task` DTO** — add fields: + +```typescript +// Add to Task type: +scheduledStart: string | null +scheduledEnd: string | null +deadline: string | null +syncToCalendar: boolean +calendarSyncError: string | null +recurrence: { + id: number + '@id'?: string + type: 'daily' | 'weekly' | 'monthly' | 'yearly' + interval: number + daysOfWeek: string[] | null + dayOfMonth: number | null + weekOfMonth: number | null + endDate: string | null + maxOccurrences: number | null + occurrenceCount: number +} | null + +// Add to TaskWrite type: +scheduledStart?: string | null +scheduledEnd?: string | null +deadline?: string | null +syncToCalendar?: boolean +recurrence?: string | null +``` + +- [ ] **Step 2: Create `task-recurrence.ts` DTO** + +```typescript +export type TaskRecurrence = { + id: number + '@id'?: string + type: 'daily' | 'weekly' | 'monthly' | 'yearly' + interval: number + daysOfWeek: string[] | null + dayOfMonth: number | null + weekOfMonth: number | null + endDate: string | null + maxOccurrences: number | null + occurrenceCount: number +} + +export type TaskRecurrenceWrite = { + type: 'daily' | 'weekly' | 'monthly' | 'yearly' + interval: number + daysOfWeek?: string[] | null + dayOfMonth?: number | null + weekOfMonth?: number | null + endDate?: string | null + maxOccurrences?: number | null +} +``` + +- [ ] **Step 3: Create `zimbra.ts` DTO and service** + +```typescript +// frontend/services/dto/zimbra.ts +export type ZimbraSettings = { + serverUrl: string | null + username: string | null + calendarPath: string | null + enabled: boolean + hasPassword: boolean +} + +export type ZimbraSettingsWrite = { + serverUrl: string | null + username: string | null + calendarPath: string | null + password?: string | null + enabled: boolean +} + +export type ZimbraTestResult = { + success: boolean +} +``` + +```typescript +// frontend/services/zimbra.ts +export function useZimbraService() { + const api = useApi() + + async function getSettings(): Promise { + return api.get('/settings/zimbra') + } + + async function saveSettings(payload: ZimbraSettingsWrite): Promise { + return api.put('/settings/zimbra', payload as Record, { + toastSuccessKey: 'zimbra.settings.saved', + }) + } + + async function testConnection(): Promise { + return api.post('/settings/zimbra/test', {}) + } + + return { getSettings, saveSettings, testConnection } +} +``` + +- [ ] **Step 4: Create `task-recurrences.ts` service** + +```typescript +export function useTaskRecurrenceService() { + const api = useApi() + + async function create(payload: TaskRecurrenceWrite): Promise { + return api.post('/task_recurrences', payload as Record, { + toastSuccessKey: 'taskRecurrence.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/task_recurrences/${id}`, payload as Record, { + toastSuccessKey: 'taskRecurrence.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/task_recurrences/${id}`, {}, { + toastSuccessKey: 'taskRecurrence.deleted', + }) + } + + return { create, update, remove } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/services/dto/task.ts frontend/services/dto/task-recurrence.ts frontend/services/dto/zimbra.ts frontend/services/zimbra.ts frontend/services/task-recurrences.ts +git commit -m "feat(ui) : add DTOs and services for calendar fields, recurrence, and Zimbra settings" +``` + +--- + +## Task 14: Add "Planification" Tab to TaskModal + +**Files:** +- Modify: `frontend/components/task/TaskModal.vue` + +- [ ] **Step 1: Add tab navigation** + +After the header `
` and before the `
`, add tab buttons: + +```vue + +
+ +
+``` + +- [ ] **Step 2: Wrap existing form content in `v-show="activeTab === 'details'"`** + +- [ ] **Step 3: Add "Planification" tab content** + +After the details `
`, add: + +```vue +
+ +
+

{{ $t('tasks.planning.dates') }}

+
+ + +
+
+ +
+
+ + +
+

{{ $t('tasks.planning.calendar') }}

+ +
+ + + {{ task.calendarSyncError || $t('tasks.planning.syncOk') }} + +
+
+ + +
+

{{ $t('tasks.planning.recurrence') }}

+ + +
+ +
+ + +
+ + + + + +
+

{{ $t('tasks.planning.daysOfWeek') }}

+
+ +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

{{ $t('tasks.planning.endRecurrence') }}

+ + + + + +
+
+
+
+``` + +- [ ] **Step 4: Add script reactive state** + +```typescript +const activeTab = ref<'details' | 'planning'>('details') + +// Add to form reactive: +const form = reactive({ + // ... existing fields ... + scheduledStart: '' as string, + scheduledEnd: '' as string, + deadline: '' as string, + syncToCalendar: false, + isRecurring: false, + recurrenceType: 'daily' as string, + recurrenceInterval: '1' as string, + recurrenceDaysOfWeek: [] as string[], + recurrenceDayOfMonth: '' as string, + monthlyMode: 'dayOfMonth' as string, + recurrenceWeekOfMonth: 1 as number, + recurrenceWeekDay: 'monday' as string, + recurrenceEnd: 'never' as string, + recurrenceMaxOccurrences: '' as string, + 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' }, +] + +function toggleDay(day: string) { + const idx = form.recurrenceDaysOfWeek.indexOf(day) + if (idx >= 0) form.recurrenceDaysOfWeek.splice(idx, 1) + else form.recurrenceDaysOfWeek.push(day) +} +``` + +- [ ] **Step 5: Update `populateForm()`** — populate calendar fields from task data +- [ ] **Step 6: Update `handleSubmit()`** — include calendar fields in payload, handle recurrence create/update via `useTaskRecurrenceService()` +- [ ] **Step 7: Reset `activeTab` to `'details'` when modal opens** + +- [ ] **Step 8: Commit** + +```bash +git add frontend/components/task/TaskModal.vue +git commit -m "feat(ui) : add Planification tab to TaskModal with dates, calendar sync, and recurrence" +``` + +--- + +## Task 15: Add Calendar Badges to TaskCard and TaskListItem + +**Files:** +- Modify: `frontend/components/task/TaskCard.vue` +- Modify: `frontend/components/task/TaskListItem.vue` + +- [ ] **Step 1: Update `TaskCard.vue`** — add after tags/priority badges, before assignee: + +```vue + + + {{ formatDeadline(task.deadline) }} + + + + + +``` + +Script additions: + +```typescript +const deadlineColor = computed(() => { + if (!props.task.deadline) return '' + const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000 + if (daysLeft < 0) return '#DC2626' + if (daysLeft < 2) return '#F59E0B' + return '#9CA3AF' +}) + +function formatDeadline(d: string): string { + return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }) +} +``` + +- [ ] **Step 2: Update `TaskListItem.vue`** — add deadline, scheduled date, and recurrence indicators (similar badges/icons) + +- [ ] **Step 3: Commit** + +```bash +git add frontend/components/task/TaskCard.vue frontend/components/task/TaskListItem.vue +git commit -m "feat(ui) : add deadline badges and calendar/recurrence icons to task cards and list items" +``` + +--- + +## Task 16: Create Admin Zimbra Tab + +**Files:** +- Create: `frontend/components/admin/AdminZimbraTab.vue` +- Modify: `frontend/pages/admin.vue` + +- [ ] **Step 1: Create `AdminZimbraTab.vue`** — follow `AdminGiteaTab.vue` pattern exactly: +- Form with: serverUrl, username, calendarPath, password, enabled toggle +- "Tester la connexion" button calling `testConnection()` +- Uses `useZimbraService()` +- Loads settings on mount, saves with toast + +- [ ] **Step 2: Update `admin.vue`** — add tab `{ key: 'zimbra', label: 'Zimbra' }` and `` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/components/admin/AdminZimbraTab.vue frontend/pages/admin.vue +git commit -m "feat(ui) : add Zimbra CalDAV configuration tab in admin page" +``` + +--- + +## Task 17: Add i18n Translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` +- Modify: `frontend/i18n/locales/en.json` + +- [ ] **Step 1: Add French translations** — keys for: +- `tasks.detailsTab`, `tasks.planningTab` +- `tasks.planning.*` (dates, scheduledStart, scheduledEnd, deadline, calendar, syncToCalendar, syncOk, recurrence, isRecurring, type, daily, weekly, monthly, yearly, interval, daysOfWeek, dayOfMonth, dayOfMonthLabel, weekOfMonth, weekOfMonthLabel, dayLabel, endRecurrence, neverEnds, afterOccurrences, occurrences, onDate, endDate) +- `zimbra.settings.*` (title, serverUrl, serverUrlPlaceholder, username, usernamePlaceholder, calendarPath, calendarPathPlaceholder, password, passwordConfigured, enabled, save, testConnection, testSuccess, testFailed, saved) +- `taskRecurrence.*` (created, updated, deleted) + +- [ ] **Step 2: Add English translations** — same keys + +- [ ] **Step 3: Commit** + +```bash +git add frontend/i18n/locales/ +git commit -m "feat(ui) : add i18n translations for calendar integration" +``` + +--- + +## Task 18: Update Fixtures + +**Files:** +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Add ZimbraConfiguration fixture** (disabled by default) + +```php +$zimbraConfig = new ZimbraConfiguration(); +$zimbraConfig->setServerUrl('https://mail.ovh.com'); +$zimbraConfig->setUsername('lesstime@ovh.fr'); +$zimbraConfig->setCalendarPath('/dav/lesstime@ovh.fr/Calendar/'); +$zimbraConfig->setEnabled(false); +$manager->persist($zimbraConfig); +``` + +- [ ] **Step 2: Add sample tasks with dates and recurrence** + +- [ ] **Step 3: Run fixtures** + +```bash +make db-reset +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/DataFixtures/AppFixtures.php +git commit -m "feat : add Zimbra config and calendar task fixtures" +``` + +--- + +## Task 19: Update CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update entity list** — add `TaskRecurrence`, `ZimbraConfiguration` +- [ ] **Step 2: Update ApiResource list** — add `ZimbraSettings`, `ZimbraTestConnection` +- [ ] **Step 3: Update State list** — add `ZimbraSettingsProvider/Processor`, `ZimbraTestConnectionProvider`, `TaskCalendarProcessor` +- [ ] **Step 4: Update Service list** — add `CalDavService`, `RecurrenceCalculator` +- [ ] **Step 5: Update MCP tools count** if needed + +- [ ] **Step 6: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs : update CLAUDE.md with Zimbra calendar integration references" +``` + +--- + +## Task 20: Manual Testing & Verification + +- [ ] **Step 1: Start dev environment** — `make start && make dev-nuxt` +- [ ] **Step 2: Test admin Zimbra config** — go to admin page, fill in Zimbra settings, test connection +- [ ] **Step 3: Test task creation with dates** — create a task, go to Planification tab, set dates and deadline +- [ ] **Step 4: Test calendar sync** — check the "Envoyer au calendrier" box, save, verify VEVENT in Zimbra +- [ ] **Step 5: Test recurrence** — create a recurring task, complete it, verify new task is auto-created with correct dates +- [ ] **Step 6: Test MCP tools** — use create-task with scheduledStart/deadline params via MCP +- [ ] **Step 7: Test edge cases** — delete a synced task, uncheck sync, remove dates