# 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.recurrence` → `ManyToOne` vers `TaskRecurrence` (nullable) - `TaskRecurrence.tasks` → `OneToMany` 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.