# 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])] #[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; public function __construct() { $this->tasks = new ArrayCollection(); } // Standard multi-line getters/setters following codebase convention (PHP CS Fixer). // All properties get standard get/set methods. // Special methods: incrementOccurrenceCount() and getVersion() } ``` - [ ] **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 and TaskNumberProcessor** **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: TaskNumberProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), ``` Modify `TaskNumberProcessor`: inject `CalDavService`, after the transaction completes call sync: ```php 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** ```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** **CRITICAL: Detect status CHANGE to isFinal, not just current isFinal.** In `TaskCalendarProcessor`, when processing a Patch: 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 - 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 - **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` - 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 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 #[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 { 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) 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 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:** - 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