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>
11 KiB
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:20260129T150000est 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:20260129T140000Zest 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:20260129est 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:20260129T100000est 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:20260129T100000affiché à 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:20260715T150000est 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 unIcsDateObjectayant une propriétélocal - THEN la méthode retourne
icsDate.date(vrai UTC), PASicsDate.local.date(fake UTC)
Scenario: jsDateToIcsDate produit du fake UTC pour ts-ics
- WHEN
jsDateToIcsDate()reçoitDate("2026-01-29T14:00:00Z")avec timezone"Europe/Paris" - THEN l'objet retourné contient
dateavecgetUTCHours() === 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()retourneicsDate.datequandlocalest présent, retourneicsDate.datequandlocalest absent, retourneicsDate.datepour 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 surgetUTCHours()/getUTCMinutes()pour les fake UTC, jamaisgetHours()qui dépend de la timezone locale