Files
Lesstime/docs/superpowers/specs/2026-03-19-zimbra-calendar-design.md
Matthieu bd01072831 docs : address spec review findings for Zimbra calendar integration
- Use TokenEncryptor for password (align with GiteaConfiguration)
- Replace Entity Listener with API Platform Processor for CalDAV sync
- Add calendarSyncError field for persistent error tracking
- Add validation rules for date fields
- Fix ICS format (VCALENDAR wrapper, UTC timezone)
- Add task number generation for recurring task auto-creation
- Add optimistic locking on TaskRecurrence
- Clear calendar UIDs on archived tasks
- Add API filters for date fields
- Document i18n for daysOfWeek
- Clarify MCP tool behavior and known limitations

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

12 KiB

Intégration Calendrier Zimbra CalDAV

Date : 2026-03-19 Statut : Validé

Objectif

Permettre de synchroniser les tâches Lesstime vers un calendrier Zimbra OVH via CalDAV. Sync one-way (push uniquement), avec support des tâches récurrentes.

Principes

  • Push uniquement : Lesstime pousse vers Zimbra, ne récupère jamais les événements existants
  • Opt-in : les tâches ne sont pas envoyées au calendrier par défaut (checkbox décochée)
  • Sync synchrone : les appels CalDAV se font au moment de l'action, timeout 5s
  • Configuration globale : un seul compte Zimbra admin pour toute l'instance
  • Calendrier d'équipe : toutes les tâches sync vont dans le même calendrier

Modèle de données

Nouveaux champs sur Task

Champ Type Nullable Default Description
scheduledStart DateTimeImmutable oui null Début du créneau planifié
scheduledEnd DateTimeImmutable oui null Fin du créneau planifié
deadline DateTimeImmutable oui null Date d'échéance
syncToCalendar bool non false Opt-in pour la sync Zimbra
calendarEventUid string oui null UID du VEVENT dans Zimbra
calendarTodoUid string oui null UID du VTODO dans Zimbra
calendarSyncError string oui null Dernière erreur de sync CalDAV (null = OK)

Règles de validation

  • scheduledEnd requiert scheduledStart (et vice versa) — les deux ou aucun
  • scheduledEnd doit être après scheduledStart
  • syncToCalendar = true sans aucune date → ignoré silencieusement (pas de sync)
  • deadline est indépendant des dates planifiées (peut exister seul)

Nouvelle entité TaskRecurrence

Champ Type Nullable Description
id int non PK auto-increment
type RecurrenceType (PHP enum) non Enum backed string : daily, weekly, monthly, yearly
interval int non Tous les X (jours/semaines/mois/ans)
daysOfWeek json oui Jours de la semaine pour hebdo, ex: ["monday","wednesday"]
dayOfMonth int oui Jour du mois pour mensuel, ex: 15
weekOfMonth int oui Semaine du mois, ex: 1 pour "le 1er X du mois"
endDate Date oui Fin de la récurrence (null = infini)
maxOccurrences int oui Nombre max d'occurrences (alternatif à endDate)
occurrenceCount int non Compteur d'occurrences créées (default 0)

Relations

  • Task.recurrenceManyToOne vers TaskRecurrence (nullable)
  • TaskRecurrence.tasksOneToMany vers Task

Nouvelle entité ZimbraConfiguration

Champ Type Nullable Description
id int non PK auto-increment
serverUrl string non URL CalDAV Zimbra
username string non Compte Zimbra
encryptedPassword string non Mot de passe chiffré via TokenEncryptor (même pattern que GiteaConfiguration)
calendarPath string non Chemin complet du calendrier, ex: /dav/user@domain.com/Calendar/
enabled bool non Activer/désactiver la sync (default false)

Service CalDAV

CalDavService

Dépendances : sabre/vobject pour la génération ICS, requêtes HTTP via Symfony\Contracts\HttpClient.

Le service utilise la ZimbraConfiguration pour construire l'URL CalDAV complète : {serverUrl}{calendarPath}{uid}.ics. Le mot de passe est déchiffré via TokenEncryptor avant chaque requête. L'authentification CalDAV se fait via HTTP Basic Auth.

Méthodes

  • createEvent(Task): string — crée un VEVENT (créneau planifié), retourne l'UID
  • createTodo(Task): string — crée un VTODO (deadline), retourne l'UID
  • updateEvent(Task): void — met à jour le VEVENT existant
  • updateTodo(Task): void — met à jour le VTODO existant
  • deleteEvent(string $uid): void — supprime le VEVENT par UID
  • deleteTodo(string $uid): void — supprime le VTODO par UID
  • testConnection(): bool — teste la connexion CalDAV

Format ICS

Toutes les dates sont envoyées en UTC (suffixe Z). Les composants sont wrappés dans un document iCalendar complet :

VEVENT (créneau planifié) :

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lesstime//CalDAV//EN
BEGIN:VEVENT
UID:{calendarEventUid}
SUMMARY:[PROJET-NUM] Titre de la tâche
DTSTART:{scheduledStart en UTC, format 20260319T140000Z}
DTEND:{scheduledEnd en UTC}
DESCRIPTION:{description}\n\nLesstime: {url}
RRULE:{rrule si récurrence}
END:VEVENT
END:VCALENDAR

VTODO (deadline) :

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lesstime//CalDAV//EN
BEGIN:VTODO
UID:{calendarTodoUid}
SUMMARY:[PROJET-NUM] Titre de la tâche (deadline)
DUE:{deadline en UTC}
DESCRIPTION:{description}\n\nLesstime: {url}
END:VTODO
END:VCALENDAR

Pas de RRULE sur le VTODO — il suit la tâche courante uniquement.

Logique de sync

Déclenchement

Un API Platform State Processor (TaskCalendarProcessor) qui décore le persist/remove processor. La sync CalDAV est appelée après le flush en BDD, jamais pendant la transaction. Cela garantit :

  • La tâche est sauvegardée même si Zimbra est down
  • Pas de blocage de transaction DB par les appels HTTP

Pour les MCP tools, le CalDavService doit être appelé explicitement après le flush() dans chaque tool qui modifie les champs liés au calendrier (create-task, update-task, delete-task).

Matrice d'actions

Action Lesstime Effet CalDAV
Tâche créée/modifiée avec syncToCalendar=true et dates renseignées Crée ou met à jour VEVENT + VTODO
syncToCalendar décoché Supprime VEVENT + VTODO si existants
Tâche supprimée Supprime VEVENT + VTODO si existants
Tâche récurrente passe en isFinal Tâche archivée (archived=true), événements conservés dans Zimbra. Nouvelle tâche créée pointant vers le même VEVENT récurrent
Dates retirées Supprime les events correspondants

Gestion des erreurs

  • Timeout CalDAV : 5 secondes
  • En cas d'échec : la tâche est quand même sauvegardée en BDD, un toast d'erreur est affiché côté frontend
  • L'erreur est persistée dans calendarSyncError (visible dans l'UI comme indicateur rouge)
  • Les UIDs CalDAV restent null si la création a échoué
  • En cas de succès après un échec précédent, calendarSyncError est remis à null

Tâches récurrentes

Comportement

  1. L'utilisateur crée une tâche avec récurrence dans Lesstime
  2. Zimbra : un seul VEVENT avec RRULE est créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement
  3. Lesstime : une seule tâche existe à la fois
  4. Quand la tâche passe en statut isFinal :
    • La tâche est archivée automatiquement (archived = true)
    • Les événements Zimbra sont conservés (historique)
    • Les calendarEventUid et calendarTodoUid de la tâche archivée sont vidés (null) pour éviter toute modification accidentelle de l'événement Zimbra depuis une tâche archivée
    • Une nouvelle tâche est créée avec :
      • Même titre, description, assigné, tags, projet, groupe, effort, priorité
      • Nouveau number généré via findMaxNumberByProjectForUpdate (même pattern transactionnel que TaskNumberProcessor)
      • Statut réinitialisé au premier statut (position la plus basse)
      • Dates recalculées selon le pattern de récurrence (prochaine date selon le pattern, indépendamment de quand la tâche a été terminée)
      • calendarEventUid pointant vers le même VEVENT récurrent
      • Nouveau calendarTodoUid (nouvelle deadline)
      • occurrenceCount incrémenté sur TaskRecurrence (avec lock optimiste @ORM\Version pour éviter les doublons en cas de concurrence)
  5. Si maxOccurrences ou endDate atteint, la récurrence s'arrête (pas de nouvelle tâche créée)

Calcul de la prochaine date

La prochaine date est calculée à partir de la date planifiée de la tâche courante (pas de la date de complétion) :

  • Daily : scheduledStart + interval jours
  • Weekly : prochain jour de daysOfWeek à partir de scheduledStart + interval semaines
  • Monthly : même dayOfMonth ou même weekOfMonth+jour, mois + interval
  • Yearly : même date, année + interval

La durée du créneau (scheduledEnd - scheduledStart) est conservée.

Frontend

Onglet "Planification" dans TaskModal

La modale tâche existante aura 2 onglets :

Onglet "Détails" (existant) : titre, description, statut, priorité, effort, assigné, tags, groupe

Onglet "Planification" (nouveau) :

Bloc Dates

  • Date planifiée début (datetime-local picker)
  • Date planifiée fin (datetime-local picker)
  • Deadline (date picker)

Bloc Calendrier

  • Checkbox "Envoyer au calendrier" (décoché par défaut)
  • Indicateur de statut sync (icône verte si sync OK, rouge si erreur, gris si non configuré)

Bloc Récurrence

  • Toggle "Tâche récurrente"
  • Si activé :
    • Type : Quotidien / Hebdomadaire / Mensuel / Annuel (select)
    • Intervalle : "Tous les X ..." (input number)
    • Conditionnel selon le type :
      • Hebdomadaire → checkboxes jours de la semaine (Lu, Ma, Me, Je, Ve, Sa, Di)
      • Mensuel → radio "Le X du mois" (input) ou "Le Xème [jour] du mois" (2 selects)
    • Fin de récurrence : radio Jamais / Après X occurrences (input) / À une date (date picker)

Affichage des dates

Cartes Kanban (TaskCard) :

  • Badge deadline coloré : rouge si dépassée, orange si < 2 jours, gris sinon
  • Icône calendrier si syncToCalendar activé
  • Icône récurrence si tâche récurrente

Vue liste (TaskListItem) :

  • Colonne "Planifié" (date début)
  • Colonne "Deadline"
  • Icône récurrence si tâche récurrente

Page "Mes tâches" :

  • Même affichage que la vue liste
  • Tri possible par deadline ou date planifiée

Page Admin — Configuration Zimbra

Nouveau bloc dans la page admin existante :

  • URL du serveur CalDAV (input text)
  • Nom d'utilisateur (input text)
  • Mot de passe (input password)
  • Chemin du calendrier (input text)
  • Toggle activer/désactiver
  • Bouton "Tester la connexion" (toast succès/erreur)

Accessible uniquement ROLE_ADMIN.

MCP Tools

Mise à jour des tools existants

create-task et update-task : nouveaux paramètres optionnels :

  • scheduledStart (string datetime ISO)
  • scheduledEnd (string datetime ISO)
  • deadline (string datetime ISO)
  • syncToCalendar (bool)

Nouveaux tools

  • create-task-recurrence — paramètres : taskId, type, interval, daysOfWeek?, dayOfMonth?, weekOfMonth?, endDate?, maxOccurrences?
  • update-task-recurrence — paramètres : recurrenceId, + mêmes champs optionnels
  • delete-task-recurrence — paramètres : recurrenceId — supprime la récurrence, nullifie la relation sur la tâche active, et supprime l'événement récurrent Zimbra si existant

API Filters

Ajouter sur Task les filtres API Platform suivants :

  • DateFilter sur scheduledStart, scheduledEnd, deadline (pour le tri et filtrage par plage de dates)
  • BooleanFilter sur syncToCalendar
  • OrderFilter sur scheduledStart, deadline

Valeurs stockées en JSON (i18n)

Les daysOfWeek dans TaskRecurrence sont stockés en anglais (monday, tuesday...) — les labels traduits sont gérés uniquement côté frontend via i18n.

Dépendances PHP

  • sabre/vobject — génération/parsing ICS (VEVENT, VTODO, RRULE)
  • symfony/http-client — requêtes HTTP CalDAV (PUT, DELETE, PROPFIND)

Limitations connues

  • Sync synchrone : si Zimbra est lent, chaque sauvegarde de tâche peut prendre jusqu'à 5s. Migration vers Symfony Messenger possible à l'avenir si nécessaire.
  • Pas de sync bidirectionnelle : les modifications faites directement dans Zimbra ne sont pas reflétées dans Lesstime.