đ(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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
100
openspec/changes/fix-timezone-double-conversion/design.md
Normal file
100
openspec/changes/fix-timezone-double-conversion/design.md
Normal 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.
|
||||
29
openspec/changes/fix-timezone-double-conversion/proposal.md
Normal file
29
openspec/changes/fix-timezone-double-conversion/proposal.md
Normal 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
|
||||
@@ -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
|
||||
80
openspec/changes/fix-timezone-double-conversion/tasks.md
Normal file
80
openspec/changes/fix-timezone-double-conversion/tasks.md
Normal 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`)
|
||||
Reference in New Issue
Block a user