📝(front) add OpenSpec artifacts for timezone double conversion fix

Add proposal, design, specs and tasks for the fix-timezone-double-conversion
change. Documents the root cause (icsDateToJsDate returning fake UTC causing
double browser offset), the Intl.DateTimeFormat-based solution, and 40+
test scenarios covering DST, cross-timezone, half-hour offsets, and
round-trip conversions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-29 11:12:58 +01:00
parent 79f352b738
commit 9e982a8eb1
5 changed files with 381 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-29

View File

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

View File

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

View File

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

View File

@@ -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`)