Files
Lesstime/docs/superpowers/plans/2026-03-19-zimbra-calendar.md
Matthieu 98370e0478 docs : fix plan review findings for Zimbra calendar integration
- Separate @Version from occurrenceCount (use dedicated version column)
- Fix processor chaining: TaskNumberProcessor for Post, TaskCalendarProcessor for Patch/Delete
- Detect status CHANGE to isFinal (not just current isFinal) to avoid duplicate recurrence
- Add DeleteTaskTool CalDAV cleanup for MCP deletions
- Add "Mes tâches" page update task (sort + columns)
- Use i18n for weekDays labels instead of hardcoded French
- Clarify documents/bookStackLinks NOT copied for recurring tasks
- Use multi-line getter/setter style note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:10:34 +01:00

51 KiB

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

docker exec -t php-lesstime-fpm composer require sabre/vobject
  • Step 2: Verify installation
docker exec -t php-lesstime-fpm composer show sabre/vobject

Expected: shows sabre/vobject version info.

  • Step 3: Commit
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

declare(strict_types=1);

namespace App\Enum;

enum RecurrenceType: string
{
    case Daily = 'daily';
    case Weekly = 'weekly';
    case Monthly = 'monthly';
    case Yearly = 'yearly';
}
  • Step 2: Commit
git add src/Enum/RecurrenceType.php
git commit -m "feat : add RecurrenceType backed enum"

Task 3: Create TaskRecurrence Entity

Files:

  • Create: src/Entity/TaskRecurrence.php

  • Create: src/Repository/TaskRecurrenceRepository.php

  • Step 1: Create the repository

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\TaskRecurrence;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<TaskRecurrence>
 */
class TaskRecurrenceRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, TaskRecurrence::class);
    }
}
  • Step 2: Create the entity
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\RecurrenceType;
use App\Repository\TaskRecurrenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['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<int, Task> */
    #[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
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

declare(strict_types=1);

namespace App\Repository;

use App\Entity\ZimbraConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<ZimbraConfiguration>
 */
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

declare(strict_types=1);

namespace App\Entity;

use App\Repository\ZimbraConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ZimbraConfigurationRepository::class)]
class ZimbraConfiguration
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Url]
    private ?string $serverUrl = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $username = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $encryptedPassword = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $calendarPath = null;

    #[ORM\Column(type: 'boolean', options: ['default' => 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
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):

#[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
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
#[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
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

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

make migration-migrate
  • Step 4: Commit
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:

  • DailyFREQ=DAILY;INTERVAL={interval}
  • WeeklyFREQ=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)
  • YearlyFREQ=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
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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\ZimbraSettingsProcessor;
use App\State\ZimbraSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/settings/zimbra',
            normalizationContext: ['groups' => ['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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\ZimbraTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Post(
            uriTemplate: '/settings/zimbra/test',
            input: false,
            normalizationContext: ['groups' => ['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
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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task;
use App\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * @implements ProcessorInterface<Task, Task>
 */
final readonly class TaskCalendarProcessor implements ProcessorInterface
{
    /**
     * @param ProcessorInterface<Task, Task> $persistProcessor
     * @param ProcessorInterface<Task, Task> $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:

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:

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
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

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

#[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

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:

// 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
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
// 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
}
// frontend/services/zimbra.ts
export function useZimbraService() {
    const api = useApi()

    async function getSettings(): Promise<ZimbraSettings> {
        return api.get<ZimbraSettings>('/settings/zimbra')
    }

    async function saveSettings(payload: ZimbraSettingsWrite): Promise<ZimbraSettings> {
        return api.put<ZimbraSettings>('/settings/zimbra', payload as Record<string, unknown>, {
            toastSuccessKey: 'zimbra.settings.saved',
        })
    }

    async function testConnection(): Promise<ZimbraTestResult> {
        return api.post<ZimbraTestResult>('/settings/zimbra/test', {})
    }

    return { getSettings, saveSettings, testConnection }
}
  • Step 4: Create task-recurrences.ts service
export function useTaskRecurrenceService() {
    const api = useApi()

    async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
        return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
            toastSuccessKey: 'taskRecurrence.created',
        })
    }

    async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
        return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
            toastSuccessKey: 'taskRecurrence.updated',
        })
    }

    async function remove(id: number): Promise<void> {
        await api.delete(`/task_recurrences/${id}`, {}, {
            toastSuccessKey: 'taskRecurrence.deleted',
        })
    }

    return { create, update, remove }
}
  • Step 5: Commit
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 <div> and before the <form>, add tab buttons:

<!-- Tabs -->
<div class="border-b border-neutral-100 px-4 sm:px-8">
    <nav class="flex gap-6">
        <button
            v-for="tab in ['details', 'planning']"
            :key="tab"
            type="button"
            class="px-1 pb-3 text-sm font-semibold transition"
            :class="activeTab === tab
                ? 'border-b-2 border-primary-500 text-primary-500'
                : 'text-neutral-500 hover:text-neutral-700'"
            @click="activeTab = tab"
        >
            {{ $t(`tasks.${tab}Tab`) }}
        </button>
    </nav>
</div>
  • Step 2: Wrap existing form content in v-show="activeTab === 'details'"

  • Step 3: Add "Planification" tab content

After the details <div>, add:

<div v-show="activeTab === 'planning'" class="space-y-6">
    <!-- Dates -->
    <div>
        <h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.dates') }}</h3>
        <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
            <MalioInputText
                v-model="form.scheduledStart"
                :label="$t('tasks.planning.scheduledStart')"
                type="datetime-local"
                input-class="w-full"
            />
            <MalioInputText
                v-model="form.scheduledEnd"
                :label="$t('tasks.planning.scheduledEnd')"
                type="datetime-local"
                input-class="w-full"
            />
        </div>
        <div class="mt-4">
            <MalioInputText
                v-model="form.deadline"
                :label="$t('tasks.planning.deadline')"
                type="date"
                input-class="w-full sm:w-1/2"
            />
        </div>
    </div>

    <!-- Calendar sync -->
    <div class="rounded-lg border border-neutral-200 p-4">
        <h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.calendar') }}</h3>
        <label class="flex items-center gap-3">
            <input
                v-model="form.syncToCalendar"
                type="checkbox"
                class="rounded border-neutral-300"
            />
            <span class="text-sm">{{ $t('tasks.planning.syncToCalendar') }}</span>
        </label>
        <div v-if="isEditing && task?.syncToCalendar" class="mt-3 flex items-center gap-2">
            <Icon
                :name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:check-circle'"
                :class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
                size="18"
            />
            <span class="text-xs" :class="task.calendarSyncError ? 'text-red-600' : 'text-green-600'">
                {{ task.calendarSyncError || $t('tasks.planning.syncOk') }}
            </span>
        </div>
    </div>

    <!-- Recurrence -->
    <div class="rounded-lg border border-neutral-200 p-4">
        <h3 class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('tasks.planning.recurrence') }}</h3>
        <label class="flex items-center gap-3">
            <input
                v-model="form.isRecurring"
                type="checkbox"
                class="rounded border-neutral-300"
            />
            <span class="text-sm">{{ $t('tasks.planning.isRecurring') }}</span>
        </label>

        <div v-if="form.isRecurring" class="mt-4 space-y-4">
            <!-- Type -->
            <div>
                <label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.type') }}</label>
                <select v-model="form.recurrenceType" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
                    <option value="daily">{{ $t('tasks.planning.daily') }}</option>
                    <option value="weekly">{{ $t('tasks.planning.weekly') }}</option>
                    <option value="monthly">{{ $t('tasks.planning.monthly') }}</option>
                    <option value="yearly">{{ $t('tasks.planning.yearly') }}</option>
                </select>
            </div>

            <!-- Interval -->
            <MalioInputText
                v-model="form.recurrenceInterval"
                :label="$t('tasks.planning.interval')"
                type="number"
                input-class="w-full sm:w-1/3"
                min="1"
                max="100"
            />

            <!-- Weekly: days of week -->
            <div v-if="form.recurrenceType === 'weekly'">
                <p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('tasks.planning.daysOfWeek') }}</p>
                <div class="flex flex-wrap gap-2">
                    <label
                        v-for="day in weekDays"
                        :key="day.value"
                        class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
                        :class="form.recurrenceDaysOfWeek.includes(day.value)
                            ? 'bg-primary-500 text-white'
                            : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
                    >
                        <input
                            type="checkbox"
                            class="hidden"
                            :value="day.value"
                            :checked="form.recurrenceDaysOfWeek.includes(day.value)"
                            @change="toggleDay(day.value)"
                        />
                        {{ day.label }}
                    </label>
                </div>
            </div>

            <!-- Monthly options -->
            <div v-if="form.recurrenceType === 'monthly'" class="space-y-3">
                <div class="flex gap-4">
                    <label class="flex items-center gap-2 text-sm">
                        <input v-model="form.monthlyMode" value="dayOfMonth" type="radio" />
                        {{ $t('tasks.planning.dayOfMonth') }}
                    </label>
                    <label class="flex items-center gap-2 text-sm">
                        <input v-model="form.monthlyMode" value="weekOfMonth" type="radio" />
                        {{ $t('tasks.planning.weekOfMonth') }}
                    </label>
                </div>
                <MalioInputText
                    v-if="form.monthlyMode === 'dayOfMonth'"
                    v-model="form.recurrenceDayOfMonth"
                    :label="$t('tasks.planning.dayOfMonthLabel')"
                    type="number"
                    input-class="w-full sm:w-1/3"
                    min="1"
                    max="31"
                />
                <div v-if="form.monthlyMode === 'weekOfMonth'" class="grid grid-cols-2 gap-4">
                    <div>
                        <label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.weekOfMonthLabel') }}</label>
                        <select v-model="form.recurrenceWeekOfMonth" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
                            <option :value="1">1er</option>
                            <option :value="2">2ème</option>
                            <option :value="3">3ème</option>
                            <option :value="4">4ème</option>
                        </select>
                    </div>
                    <div>
                        <label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('tasks.planning.dayLabel') }}</label>
                        <select v-model="form.recurrenceWeekDay" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm">
                            <option v-for="day in weekDays" :key="day.value" :value="day.value">{{ day.label }}</option>
                        </select>
                    </div>
                </div>
            </div>

            <!-- End of recurrence -->
            <div class="space-y-3">
                <p class="text-sm font-medium text-neutral-700">{{ $t('tasks.planning.endRecurrence') }}</p>
                <label class="flex items-center gap-2 text-sm">
                    <input v-model="form.recurrenceEnd" value="never" type="radio" />
                    {{ $t('tasks.planning.neverEnds') }}
                </label>
                <label class="flex items-center gap-2 text-sm">
                    <input v-model="form.recurrenceEnd" value="occurrences" type="radio" />
                    {{ $t('tasks.planning.afterOccurrences') }}
                </label>
                <MalioInputText
                    v-if="form.recurrenceEnd === 'occurrences'"
                    v-model="form.recurrenceMaxOccurrences"
                    :label="$t('tasks.planning.occurrences')"
                    type="number"
                    input-class="w-full sm:w-1/3"
                    min="1"
                />
                <label class="flex items-center gap-2 text-sm">
                    <input v-model="form.recurrenceEnd" value="date" type="radio" />
                    {{ $t('tasks.planning.onDate') }}
                </label>
                <MalioInputText
                    v-if="form.recurrenceEnd === 'date'"
                    v-model="form.recurrenceEndDate"
                    :label="$t('tasks.planning.endDate')"
                    type="date"
                    input-class="w-full sm:w-1/2"
                />
            </div>
        </div>
    </div>
</div>
  • Step 4: Add script reactive state
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

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:

<!-- Deadline badge -->
<span
    v-if="task.deadline"
    class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
    :style="{ backgroundColor: deadlineColor }"
    :title="task.deadline"
>
    {{ formatDeadline(task.deadline) }}
</span>
<!-- Calendar sync icon -->
<Icon
    v-if="task.syncToCalendar"
    :name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
    :class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
    size="14"
/>
<!-- Recurrence icon -->
<Icon
    v-if="task.recurrence"
    name="mdi:repeat"
    class="text-blue-500"
    size="14"
/>

Script additions:

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

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

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 <AdminZimbraTab v-if="activeTab === 'zimbra'" />

  • Step 3: Commit

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

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)

$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

make db-reset
  • Step 4: Commit
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

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 environmentmake 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