diff --git a/openspec/changes/fix-timezone-double-conversion/.openspec.yaml b/openspec/changes/fix-timezone-double-conversion/.openspec.yaml new file mode 100644 index 0000000..e85ca8e --- /dev/null +++ b/openspec/changes/fix-timezone-double-conversion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-29 diff --git a/openspec/changes/fix-timezone-double-conversion/design.md b/openspec/changes/fix-timezone-double-conversion/design.md new file mode 100644 index 0000000..519c0ff --- /dev/null +++ b/openspec/changes/fix-timezone-double-conversion/design.md @@ -0,0 +1,100 @@ +## Context + +L'application utilise un pattern "fake UTC" pour communiquer avec la librairie `ts-ics`. Ce pattern consiste à créer des objets `Date` JavaScript dont les composants UTC (`getUTCHours()`, etc.) représentent l'heure locale voulue, car `ts-ics` utilise `getUTCHours()` pour générer les chaînes ICS. + +Le problème : ce pattern fuit dans le chemin d'affichage. La méthode `icsDateToJsDate()` retourne `icsDate.local.date` (un fake UTC) et `dateToLocalISOString()` appelle `getHours()` dessus. Le navigateur ajoute alors son propre offset timezone, ce qui double la conversion. + +``` +ts-ics parse: DTSTART;TZID=Europe/Paris:20260129T150000 + + date = Date(UTC 14:00) ← vrai UTC (15h Paris - 1h offset) + local.date = Date(UTC 15:00) ← fake UTC (getUTCHours=15) + +Actuel (bugué): + icsDateToJsDate() → local.date → Date(UTC 15:00) + dateToLocalISOString() → getHours() → 16h (navigateur ajoute +1h) + Affichage: 16:00 ❌ + +Corrigé: + icsDateToJsDate() → date → Date(UTC 14:00) + dateToLocalISOString() → getHours() → 15h (navigateur convertit correctement) + Affichage: 15:00 ✅ +``` + +## Goals / Non-Goals + +**Goals:** + +- Corriger l'affichage des événements dans le scheduler (suppression du décalage timezone) +- Gérer correctement les événements cross-timezone (event créé à New York, vu depuis Paris) +- Gérer correctement les transitions DST (heure d'été/hiver) +- Confiner le pattern fake UTC au strict minimum : le point d'entrée vers `ts-ics` + +**Non-Goals:** + +- Remplacer `ts-ics` par une autre librairie +- Brancher le champ `timezone` du profil utilisateur (prévu mais hors scope) +- Ajouter un sélecteur de timezone dans l'UI +- Modifier le backend Django ou le CalDAV server SabreDAV +- Modifier l'EventModal ou les dateFormatters (ils fonctionnent correctement) + +## Decisions + +### Decision 1 : Retourner le vrai UTC dans `icsDateToJsDate()` + +**Choix** : Retourner `icsDate.date` (vrai UTC) au lieu de `icsDate.local.date` (fake UTC). + +**Rationale** : `dateToLocalISOString()` utilise `getHours()` qui applique automatiquement l'offset du navigateur. Avec un vrai UTC en entrée, la conversion navigateur donne directement l'heure locale correcte. Plus besoin de flag `isFakeUtc` dans le chemin d'affichage. + +**Alternative rejetée** : Modifier `dateToLocalISOString()` pour utiliser `getUTCHours()` quand la date est fake UTC. Rejeté car cela propagerait le concept de fake UTC plus loin dans le code au lieu de le contenir. + +### Decision 2 : Utiliser `Intl.DateTimeFormat` pour la conversion timezone dans `jsDateToIcsDate()` + +**Choix** : Ajouter un helper `getDateComponentsInTimezone(date, timezone)` qui utilise `Intl.DateTimeFormat` avec le paramètre `timeZone` pour extraire les composants (year, month, day, hours, minutes, seconds) dans le timezone cible. Utiliser ces composants pour créer le fake UTC destiné à `ts-ics`. + +**Rationale** : C'est la seule approche qui gère correctement : +1. **Même timezone** : event créé et vu en France → `Intl(tz=Europe/Paris)` donne l'heure locale française +2. **Cross-timezone** : event créé à NY, vu en France → `Intl(tz=America/New_York)` donne l'heure new-yorkaise +3. **DST** : transitions automatiquement gérées par le moteur `Intl` du navigateur + +**Alternative rejetée** : Calculer l'offset manuellement avec `getTimezoneOffset()`. Rejeté car ne gère pas les transitions DST correctement pour les timezones arbitraires. + +### Decision 3 : Conserver le fake UTC au point d'entrée ts-ics + +**Choix** : Le fake UTC reste dans `jsDateToIcsDate()` (adapter) et `handleSave()` (EventModal) — les deux seuls endroits qui produisent des `IcsEvent` pour `ts-ics`. + +**Rationale** : `ts-ics` utilise `date.getUTCHours()` pour générer les chaînes ICS (`DTSTART;TZID=Europe/Paris:20260129T150000`). C'est une contrainte de la librairie qui ne peut pas être contournée sans la forker. Le fake UTC est le pattern correct pour cette interface — le problème n'était pas le pattern lui-même, mais sa fuite dans le chemin d'affichage. + +``` + Frontière fake UTC + │ + Affichage (vrai UTC) │ ts-ics (fake UTC) + ─────────────────────────┼─────────────────── + icsDateToJsDate() │ jsDateToIcsDate() + dateToLocalISOString() │ handleSave() + EventCalendar UI │ generateIcsCalendar() + │ + getHours() → local OK │ getUTCHours() → local OK +``` + +### Decision 4 : Supprimer le paramètre `isFakeUtc` de `jsDateToIcsDate()` + +**Choix** : Le paramètre `isFakeUtc` est remplacé par la conversion explicite via `Intl.DateTimeFormat`. La méthode reçoit toujours un vrai UTC (ou une Date en heure locale du navigateur — c'est le même objet JS, seule l'interprétation change) et le convertit dans le timezone cible. + +**Rationale** : L'ancien code avait deux chemins : +- `isFakeUtc = true` → passe la date telle quelle (suppose que les composants UTC sont déjà corrects) +- `isFakeUtc = false` → copie les composants locaux (`getHours()`) dans un nouveau `Date.UTC()` + +Le nouveau code n'a qu'un seul chemin : extraire les composants dans le timezone cible via `Intl`, puis créer le fake UTC. Cela élimine une catégorie entière de bugs (mauvaise valeur de `isFakeUtc`). + +## Risks / Trade-offs + +**[Régression EventModal]** → Le modal reçoit des IcsEvent avec des dates fake UTC (produites par `jsDateToIcsDate`). Il utilise `isFakeUtc` + `getUTCHours()` pour les lire. Ce chemin n'est pas modifié et reste correct. Vérifié : le `isFakeUtc` dans le modal détecte `event.start.local?.timezone`, qui est toujours présent sur les dates fake UTC produites par l'adapter. + +**[Performance Intl.DateTimeFormat]** → `Intl.DateTimeFormat` crée un formateur à chaque appel. Mitigation : impact négligeable car appelé uniquement à la sauvegarde (pas à chaque rendu). Si nécessaire, on peut cacher les formateurs par timezone. + +**[Navigateurs anciens]** → `Intl.DateTimeFormat` avec `formatToParts()` est supporté depuis Chrome 56, Firefox 51, Safari 11. Tous les navigateurs cibles de l'app le supportent. Déjà utilisé dans `getTimezoneOffset()`. + +**[Events sans TZID (UTC pur)]** → `DTSTART:20260129T150000Z` → ts-ics produit `date = Date(UTC 15:00)` sans propriété `local`. `icsDateToJsDate()` retourne déjà `date` dans ce cas. Aucun changement de comportement. + +**[Events all-day]** → `DTSTART;VALUE=DATE:20260129` → ts-ics produit `date = Date.UTC(2026, 0, 29)` sans `local`. `icsDateToJsDate()` retourne déjà `date`. `dateToDateOnlyString()` utilise `getUTCFullYear/Month/Date()`. Aucun changement de comportement. diff --git a/openspec/changes/fix-timezone-double-conversion/proposal.md b/openspec/changes/fix-timezone-double-conversion/proposal.md new file mode 100644 index 0000000..7c356c9 --- /dev/null +++ b/openspec/changes/fix-timezone-double-conversion/proposal.md @@ -0,0 +1,29 @@ +## Why + +Le scheduler affiche les événements avec un décalage d'une heure par rapport à l'heure réelle. La cause : une double application de l'offset timezone lors de la conversion des dates ICS vers l'affichage. Le pattern "fake UTC" utilisé pour communiquer avec ts-ics fuit dans le chemin d'affichage, où `local.date` (fake UTC) est lu avec `getHours()` (qui ajoute l'offset navigateur), créant un décalage de ±N heures selon la timezone. Ce bug corrompt aussi les données lors de drag & drop, car l'utilisateur corrige visuellement des positions déjà décalées. + +## What Changes + +- Corriger `icsDateToJsDate()` pour retourner le vrai UTC (`icsDate.date`) au lieu du fake UTC (`icsDate.local.date`), corrigeant l'affichage scheduler +- Ajouter un helper `getDateComponentsInTimezone(date, timezone)` utilisant `Intl.DateTimeFormat` pour extraire les composants date/heure dans n'importe quel timezone cible +- Refactorer `jsDateToIcsDate()` pour supprimer le paramètre `isFakeUtc` et utiliser le nouveau helper — le fake UTC n'est plus créé par copie aveugle mais par conversion explicite via Intl API +- Nettoyer `toIcsEvent()` pour supprimer la variable `isFakeUtc` et la logique conditionnelle associée +- Conserver le pattern fake UTC **uniquement** au point d'entrée vers ts-ics (`jsDateToIcsDate` et `EventModal.handleSave`), car ts-ics utilise `getUTCHours()` pour générer l'ICS — c'est une contrainte de la librairie, pas un choix d'architecture + +## Capabilities + +### New Capabilities + +- `timezone-conversion`: Gestion correcte des conversions de dates entre timezones dans l'adapter CalDAV ↔ EventCalendar, incluant le support cross-timezone et DST via Intl API + +### Modified Capabilities + +_(aucune capability existante modifiée au niveau des specs)_ + +## Impact + +- **Code frontend** : `EventCalendarAdapter.ts` (3 méthodes modifiées, 1 ajoutée), `toIcsEvent` simplifié +- **Pas de changement** : `EventModal.tsx`, `dateFormatters.ts`, `useSchedulerHandlers.ts`, backend Django, CalDAV server +- **Pas de changement d'API** : le format ICS envoyé au CalDAV server reste identique (DTSTART avec TZID) +- **Dépendances** : aucune nouvelle dépendance (utilise `Intl.DateTimeFormat` natif du navigateur) +- **Risque** : faible — le changement est isolé dans l'adapter, les tests existants couvrent le round-trip diff --git a/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md b/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md new file mode 100644 index 0000000..e0de7f2 --- /dev/null +++ b/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md @@ -0,0 +1,170 @@ +## ADDED Requirements + +### Requirement: Affichage correct des événements timezone-aware dans le scheduler + +Le système SHALL afficher les événements avec TZID à l'heure locale correcte du navigateur dans le scheduler. La méthode `icsDateToJsDate()` MUST retourner `icsDate.date` (vrai UTC) pour que `dateToLocalISOString()` produise l'heure locale correcte via `getHours()`. + +#### Scenario: Événement Europe/Paris vu depuis la France + +- **WHEN** un événement `DTSTART;TZID=Europe/Paris:20260129T150000` est affiché dans un navigateur en France (UTC+1 hiver) +- **THEN** le scheduler affiche l'événement à 15:00 + +#### Scenario: Événement UTC pur vu depuis la France + +- **WHEN** un événement `DTSTART:20260129T140000Z` est affiché dans un navigateur en France (UTC+1 hiver) +- **THEN** le scheduler affiche l'événement à 15:00 + +#### Scenario: Événement all-day + +- **WHEN** un événement `DTSTART;VALUE=DATE:20260129` est affiché +- **THEN** le scheduler affiche l'événement le 29 janvier sans décalage de jour + +### Requirement: Conversion cross-timezone correcte à l'écriture + +Le système SHALL convertir les dates de l'heure locale du navigateur vers le timezone cible de l'événement lors de la sauvegarde. La conversion MUST utiliser `Intl.DateTimeFormat` avec le paramètre `timeZone` pour extraire les composants date/heure dans le timezone cible. + +#### Scenario: Sauvegarde d'un événement local (même timezone) + +- **WHEN** un utilisateur en France crée un événement à 15:00 avec timezone `Europe/Paris` +- **THEN** le système génère `DTSTART;TZID=Europe/Paris:20260129T150000` + +#### Scenario: Sauvegarde d'un événement cross-timezone sans modification + +- **WHEN** un événement `DTSTART;TZID=America/New_York:20260129T100000` est ouvert et sauvegardé sans modification depuis un navigateur en France +- **THEN** le système génère `DTSTART;TZID=America/New_York:20260129T100000` (heure NY préservée) + +#### Scenario: Drag & drop d'un événement cross-timezone + +- **WHEN** un événement `DTSTART;TZID=America/New_York:20260129T100000` affiché à 16:00 heure de Paris est déplacé à 17:00 sur le scheduler +- **THEN** le système génère `DTSTART;TZID=America/New_York:20260129T110000` (déplacement de +1h dans le timezone NY) + +### Requirement: Gestion correcte des transitions DST + +Le système SHALL gérer correctement les événements qui traversent une transition d'heure d'été/hiver. La conversion MUST utiliser `Intl.DateTimeFormat` qui résout automatiquement l'offset DST pour la date spécifique. + +#### Scenario: Événement en été vu depuis l'hiver + +- **WHEN** un événement `DTSTART;TZID=Europe/Paris:20260715T150000` (CEST, UTC+2) est affiché dans un navigateur en France en janvier (CET, UTC+1) +- **THEN** le scheduler affiche l'événement à 15:00 (l'heure Paris est préservée indépendamment du DST du navigateur) + +#### Scenario: Round-trip d'un événement été + +- **WHEN** un événement `DTSTART;TZID=Europe/Paris:20260715T150000` est ouvert et sauvegardé sans modification +- **THEN** le système génère `DTSTART;TZID=Europe/Paris:20260715T150000` (offset CEST correctement calculé par Intl) + +### Requirement: Helper getDateComponentsInTimezone + +Le système SHALL fournir une méthode `getDateComponentsInTimezone(date: Date, timezone: string)` qui retourne les composants (year, month, day, hours, minutes, seconds) d'un instant UTC dans le timezone cible. Cette méthode MUST utiliser `Intl.DateTimeFormat` avec `formatToParts()`. + +#### Scenario: Extraction des composants Europe/Paris en hiver + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T14:00:00Z"), "Europe/Paris")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 0, seconds: 0 }` + +#### Scenario: Extraction des composants America/New_York en hiver + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T15:00:00Z"), "America/New_York")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 10, minutes: 0, seconds: 0 }` + +#### Scenario: Extraction des composants Europe/Paris en été (CEST) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-07-15T13:00:00Z"), "Europe/Paris")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 7, day: 15, hours: 15, minutes: 0, seconds: 0 }` (UTC+2 en été) + +#### Scenario: Extraction des composants Asia/Tokyo (pas de DST) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T06:00:00Z"), "Asia/Tokyo")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 0, seconds: 0 }` (UTC+9, jamais de DST) + +#### Scenario: Changement de jour par conversion timezone (UTC tard → jour suivant en avance) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T23:00:00Z"), "Asia/Tokyo")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 30, hours: 8, minutes: 0, seconds: 0 }` (23h UTC + 9h = 8h le lendemain) + +#### Scenario: Changement de jour par conversion timezone (UTC tôt → jour précédent en retard) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T03:00:00Z"), "America/New_York")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 28, hours: 22, minutes: 0, seconds: 0 }` (3h UTC - 5h = 22h la veille) + +#### Scenario: Changement d'année par conversion timezone + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-01T00:30:00Z"), "America/Los_Angeles")` est appelé +- **THEN** le résultat contient `{ year: 2025, month: 12, day: 31, hours: 16, minutes: 30, seconds: 0 }` (UTC-8 en hiver) + +#### Scenario: UTC comme timezone cible + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T15:30:45Z"), "UTC")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 30, seconds: 45 }` + +#### Scenario: Minutes et secondes non-zéro + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T14:37:42Z"), "Europe/Paris")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 37, seconds: 42 }` + +#### Scenario: Offset demi-heure (Inde) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T10:00:00Z"), "Asia/Kolkata")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 30, seconds: 0 }` (UTC+5:30) + +#### Scenario: Offset 45 minutes (Népal) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-01-29T10:00:00Z"), "Asia/Kathmandu")` est appelé +- **THEN** le résultat contient `{ year: 2026, month: 1, day: 29, hours: 15, minutes: 45, seconds: 0 }` (UTC+5:45) + +#### Scenario: Proche de la transition DST Europe/Paris (dernier dimanche de mars) + +- **WHEN** `getDateComponentsInTimezone(Date("2026-03-29T00:30:00Z"), "Europe/Paris")` est appelé (avant transition, encore CET) +- **THEN** le résultat contient `{ hours: 1, minutes: 30 }` (UTC+1) + +#### Scenario: Après la transition DST Europe/Paris + +- **WHEN** `getDateComponentsInTimezone(Date("2026-03-29T02:00:00Z"), "Europe/Paris")` est appelé (après transition, CEST) +- **THEN** le résultat contient `{ hours: 4, minutes: 0 }` (UTC+2) + +### Requirement: Confinement du fake UTC à l'interface ts-ics + +Le pattern fake UTC (objets Date dont les composants UTC représentent l'heure locale) MUST être confiné à deux endroits uniquement : `jsDateToIcsDate()` dans l'adapter et `handleSave()` dans l'EventModal. Aucun autre code NE DOIT créer ou consommer de dates fake UTC pour l'affichage. + +#### Scenario: Le chemin d'affichage n'utilise pas de fake UTC + +- **WHEN** `icsDateToJsDate()` est appelé avec un `IcsDateObject` ayant une propriété `local` +- **THEN** la méthode retourne `icsDate.date` (vrai UTC), PAS `icsDate.local.date` (fake UTC) + +#### Scenario: jsDateToIcsDate produit du fake UTC pour ts-ics + +- **WHEN** `jsDateToIcsDate()` reçoit `Date("2026-01-29T14:00:00Z")` avec timezone `"Europe/Paris"` +- **THEN** l'objet retourné contient `date` avec `getUTCHours() === 15` (fake UTC pour ts-ics) + +### Requirement: Tests unitaires exhaustifs des utilitaires de conversion timezone + +Le changement MUST inclure un fichier de tests dédié (`__tests__/timezone-conversion.test.ts`) couvrant tous les scénarios de conversion. Les tests MUST utiliser des dates UTC explicites pour être déterministes indépendamment de la timezone de la machine de CI. + +#### Scenario: Tests de getDateComponentsInTimezone couvrent toutes les catégories + +- **WHEN** la suite de tests est exécutée +- **THEN** les tests couvrent : timezones positives (Europe/Paris, Asia/Tokyo), timezones négatives (America/New_York, America/Los_Angeles), timezone UTC, offsets demi-heure (Asia/Kolkata UTC+5:30), offsets 45min (Asia/Kathmandu UTC+5:45), changements de jour par conversion, changement d'année par conversion, transitions DST (CET→CEST mars, CEST→CET octobre), minutes et secondes non-zéro + +#### Scenario: Tests de icsDateToJsDate couvrent la correction du bug + +- **WHEN** la suite de tests est exécutée +- **THEN** les tests vérifient que `icsDateToJsDate()` retourne `icsDate.date` quand `local` est présent, retourne `icsDate.date` quand `local` est absent, retourne `icsDate.date` pour les DATE type (all-day) + +#### Scenario: Tests de jsDateToIcsDate couvrent la conversion timezone correcte + +- **WHEN** la suite de tests est exécutée +- **THEN** les tests vérifient : all-day event produit DATE type, timed event produit DATE-TIME avec timezone, fake UTC a les bons composants UTC pour Europe/Paris hiver, fake UTC a les bons composants UTC pour America/New_York hiver, fake UTC a les bons composants UTC pour Asia/Tokyo, fake UTC a les bons composants UTC pour Europe/Paris été (DST), fake UTC préserve minutes et secondes + +#### Scenario: Tests de round-trip (parse → adapter → display → adapter → generate) + +- **WHEN** la suite de tests est exécutée +- **THEN** les tests vérifient le round-trip complet pour : événement Europe/Paris hiver, événement Europe/Paris été, événement America/New_York, événement Asia/Tokyo, événement UTC pur, événement all-day, événement cross-timezone (NY vu depuis Paris) + +#### Scenario: Tests de getTimezoneOffset couvrent les cas limites + +- **WHEN** la suite de tests est exécutée +- **THEN** les tests vérifient : offset positif (Europe/Paris → "+0100" hiver, "+0200" été), offset négatif (America/New_York → "-0500" hiver, "-0400" été), offset zéro (UTC → "+0000"), offset demi-heure (Asia/Kolkata → "+0530"), timezone invalide retourne "+0000" + +#### Scenario: Les tests sont déterministes en CI + +- **WHEN** les tests sont exécutés sur une machine de CI (timezone potentiellement différente) +- **THEN** tous les tests passent car ils utilisent uniquement des dates UTC explicites (`new Date("...Z")`) et des assertions sur `getUTCHours()` / `getUTCMinutes()` pour les fake UTC, jamais `getHours()` qui dépend de la timezone locale diff --git a/openspec/changes/fix-timezone-double-conversion/tasks.md b/openspec/changes/fix-timezone-double-conversion/tasks.md new file mode 100644 index 0000000..b07a2d8 --- /dev/null +++ b/openspec/changes/fix-timezone-double-conversion/tasks.md @@ -0,0 +1,80 @@ +## 1. Helper de conversion timezone + +- [x] 1.1 Ajouter le type `DateComponents` (interface avec year, month, day, hours, minutes, seconds) dans `EventCalendarAdapter.ts` +- [x] 1.2 Ajouter la méthode `getDateComponentsInTimezone(date: Date, timezone: string)` dans `EventCalendarAdapter.ts` — utilise `Intl.DateTimeFormat` avec `formatToParts()` pour extraire les composants dans le timezone cible + +## 2. Fix du chemin de lecture (affichage) + +- [x] 2.1 Modifier `icsDateToJsDate()` dans `EventCalendarAdapter.ts` pour retourner `icsDate.date` au lieu de `icsDate.local?.date` — c'est le fix principal de l'affichage scheduler + +## 3. Fix du chemin d'écriture (sauvegarde) + +- [x] 3.1 Refactorer `jsDateToIcsDate()` dans `EventCalendarAdapter.ts` : supprimer le paramètre `isFakeUtc`, utiliser `getDateComponentsInTimezone()` pour obtenir les composants dans le timezone cible, créer le fake UTC avec `Date.UTC()` à partir de ces composants +- [x] 3.2 Modifier `toIcsEvent()` dans `EventCalendarAdapter.ts` : supprimer la variable `isFakeUtc` (ligne 347) et ne plus passer ce paramètre à `jsDateToIcsDate()` + +## 4. Tests unitaires — getDateComponentsInTimezone + +Créer `src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/timezone-conversion.test.ts` + +- [x] 4.1 Test Europe/Paris hiver : `Date("2026-01-29T14:00:00Z")` → `{ hours: 15 }` (CET, UTC+1) +- [x] 4.2 Test Europe/Paris été : `Date("2026-07-15T13:00:00Z")` → `{ hours: 15 }` (CEST, UTC+2) +- [x] 4.3 Test America/New_York hiver : `Date("2026-01-29T15:00:00Z")` → `{ hours: 10 }` (EST, UTC-5) +- [x] 4.4 Test America/New_York été : `Date("2026-07-15T14:00:00Z")` → `{ hours: 10 }` (EDT, UTC-4) +- [x] 4.5 Test Asia/Tokyo (pas de DST) : `Date("2026-01-29T06:00:00Z")` → `{ hours: 15 }` (JST, UTC+9) +- [x] 4.6 Test UTC : `Date("2026-01-29T15:30:45Z")` → `{ hours: 15, minutes: 30, seconds: 45 }` +- [x] 4.7 Test changement de jour (UTC tard → lendemain en avance) : `Date("2026-01-29T23:00:00Z")` + Asia/Tokyo → `{ day: 30, hours: 8 }` +- [x] 4.8 Test changement de jour (UTC tôt → veille en retard) : `Date("2026-01-29T03:00:00Z")` + America/New_York → `{ day: 28, hours: 22 }` +- [x] 4.9 Test changement d'année : `Date("2026-01-01T00:30:00Z")` + America/Los_Angeles → `{ year: 2025, month: 12, day: 31 }` +- [x] 4.10 Test offset demi-heure (Inde) : `Date("2026-01-29T10:00:00Z")` + Asia/Kolkata → `{ hours: 15, minutes: 30 }` (UTC+5:30) +- [x] 4.11 Test offset 45min (Népal) : `Date("2026-01-29T10:00:00Z")` + Asia/Kathmandu → `{ hours: 15, minutes: 45 }` (UTC+5:45) +- [x] 4.12 Test transition DST CET→CEST (mars) : vérifier avant et après la transition du dernier dimanche de mars +- [x] 4.13 Test transition DST CEST→CET (octobre) : vérifier avant et après la transition du dernier dimanche d'octobre +- [x] 4.14 Test minutes et secondes non-zéro : `Date("2026-01-29T14:37:42Z")` + Europe/Paris → `{ hours: 15, minutes: 37, seconds: 42 }` + +## 5. Tests unitaires — icsDateToJsDate (fix du bug) + +- [x] 5.1 Test : retourne `icsDate.date` (vrai UTC) quand `local` est présent — vérifie que c'est bien `date` et PAS `local.date` +- [x] 5.2 Test : retourne `icsDate.date` quand `local` est absent (événements UTC purs) +- [x] 5.3 Test : retourne `icsDate.date` pour les événements all-day (type DATE) + +## 6. Tests unitaires — jsDateToIcsDate (conversion timezone) + +- [x] 6.1 Test all-day : produit un `IcsDateObject` de type `DATE` sans timezone +- [x] 6.2 Test Europe/Paris hiver : `Date(UTC 14:00)` + tz Paris → fake UTC avec `getUTCHours() === 15` +- [x] 6.3 Test America/New_York hiver : `Date(UTC 15:00)` + tz NY → fake UTC avec `getUTCHours() === 10` +- [x] 6.4 Test Asia/Tokyo : `Date(UTC 06:00)` + tz Tokyo → fake UTC avec `getUTCHours() === 15` +- [x] 6.5 Test Europe/Paris été (DST) : `Date(UTC 13:00)` + tz Paris → fake UTC avec `getUTCHours() === 15` (CEST, UTC+2) +- [x] 6.6 Test préservation minutes/secondes : `Date(UTC 14:37:42)` + tz Paris → fake UTC avec `getUTCMinutes() === 37`, `getUTCSeconds() === 42` +- [x] 6.7 Test changement de jour : `Date(UTC 23:00)` + tz Tokyo → fake UTC avec `getUTCDate()` = jour suivant +- [x] 6.8 Test que `local.timezone` est correctement défini dans l'objet retourné +- [x] 6.9 Test que `local.tzoffset` est correctement calculé (format "+HHMM" / "-HHMM") + +## 7. Tests unitaires — getTimezoneOffset + +- [x] 7.1 Test offset positif hiver : Europe/Paris → "+0100" +- [x] 7.2 Test offset positif été : Europe/Paris → "+0200" +- [x] 7.3 Test offset négatif hiver : America/New_York → "-0500" +- [x] 7.4 Test offset négatif été : America/New_York → "-0400" +- [x] 7.5 Test offset zéro : UTC → "+0000" +- [x] 7.6 Test offset demi-heure : Asia/Kolkata → "+0530" +- [x] 7.7 Test timezone invalide : retourne "+0000" (fallback gracieux) + +## 8. Tests unitaires — Round-trip complet (parse ICS → adapter → display string → adapter → ICS) + +- [x] 8.1 Round-trip Europe/Paris hiver : parse `DTSTART;TZID=Europe/Paris:20260129T150000` → icsDateToJsDate → dateToLocalISOString → parse string → jsDateToIcsDate → vérifier `getUTCHours() === 15` +- [x] 8.2 Round-trip Europe/Paris été : idem avec `20260715T150000` (CEST) +- [x] 8.3 Round-trip America/New_York : parse `DTSTART;TZID=America/New_York:20260129T100000` → round-trip → vérifier `getUTCHours() === 10` +- [x] 8.4 Round-trip Asia/Tokyo : parse `DTSTART;TZID=Asia/Tokyo:20260129T150000` → round-trip → vérifier `getUTCHours() === 15` +- [x] 8.5 Round-trip UTC pur : parse `DTSTART:20260129T140000Z` → round-trip (pas de TZID, utilise browser tz) +- [x] 8.6 Round-trip all-day : parse `DTSTART;VALUE=DATE:20260129` → round-trip → vérifier `getUTCDate() === 29` +- [x] 8.7 Round-trip cross-timezone (NY créé, Paris affiché) : vérifier que l'heure NY est préservée après un round-trip depuis un browser Paris + +## 9. Mise à jour des tests existants + +- [x] 9.1 Mettre à jour le test `icsDateToJsDate` dans `event-calendar-helper.test.ts` (ligne 514-533) : le test "returns local date when present" doit maintenant vérifier que c'est `icsDate.date` (vrai UTC) qui est retourné, pas `local.date` + +## 10. Vérification finale + +- [x] 10.1 Vérifier que le TypeScript compile sans erreurs (`yarn tsc --noEmit`) +- [x] 10.2 Vérifier que le linter passe (`yarn lint`) +- [x] 10.3 Vérifier que tous les tests passent (`yarn test`)