Files
calendars/openspec/changes/fix-timezone-double-conversion/specs/timezone-conversion/spec.md
Nathan Panchout 9e982a8eb1 📝(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>
2026-01-29 11:25:34 +01:00

171 lines
11 KiB
Markdown

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