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