ADR 0016 — Dates sortantes sérialisées locales et sans fuseau à la frontière HTTP
Complète ADR 0004 (les deux clients HTTP
publicFetch/apiClient). Règle métier associée :domain/business-rules.md→ « Dates sortantes : locales et sans fuseau à la frontière HTTP ».
Contexte
Les payloads sortants portaient des Date JavaScript vives (birthdates des passagers, bookingDate, checkIn/checkOut du booking), sérialisées implicitement par JSON.stringify / le bridge Capacitor via Date.toJSON() → ISO UTC (1986-04-11T22:00:00.000Z pour une naissance 1986-04-12 saisie en Suisse, minuit local).
Vérification menée dans les deux repos backend (buchard-website, horizon) :
- Le POST mobile atterrit sur
MobileBookingApiController.InitializeBooking(buchard-website, System.Text.Json sans configuration de date), qui re-sérialise en STJ par défaut et forwarde à HorizonPOST /api/bookings(Newtonsoft,DateTimeZoneHandling.RoundtripKindpar défaut). - Aucun hop ne normalise (
ToLocalTime/.Dateabsents du chemin birthdate) — leTZEurope/Zurich du serveur n'a aucun effet. La valeur UTC est stockée telle quelle endatetime2: la birthdate atterrit un jour trop tôt dans Horizon (idemcheckIn/checkOutdérivés minuit-local). Off-by-one réel, pas seulement d'affichage. - Le client web (le flux qui marche) envoie
"yyyy-MM-dd". Les deux formats sans fuseau (yyyy-MM-ddetyyyy-MM-dd'T'HH:mm:ss) sont parsésKind=Unspecifiedaux deux hops et conservent la date calendaire de bout en bout. Tout suffixeZ/+hh:mmest corrompu pour les valeurs date-only.
Le formatage par-endpoint existait déjà (customer.create/update, liste d'attente pré-formatent yyyy-MM-dd) mais n'était pas systématique — paymentApi.initialize, le payload le plus riche en dates, passait au travers. Ce bug en est la preuve : la discipline par-endpoint ne tient pas.
Décision
Un sérialiseur contrôlé unique à la frontière HTTP : serializeWireDates (src/utils/date-formatters.ts), walker pur et récursif appliqué aux deux clients — publicFetch (services/api.ts) et apiClient.request (services/api-client.ts, sérialisé une fois, réutilisé par le retry 401).
- Toute
Date→ date-fnsformatDate(d, "yyyy-MM-dd'T'HH:mm:ss")(heure locale, sans fuseau). Format uniforme : pas d'heuristique « minuit = date-only » (un vrai datetime à minuit est indistinguable) ; les date-only gardent leur jour calendaire (T00:00:00), les datetimes leur heure murale. Dateinvalide →null(paritéJSON.stringify) ; strings jamais réécrites (les pré-formatagesyyyy-MM-ddexistants restent une défense en profondeur).- Retourne une nouvelle structure — les payloads partagent des objets réactifs des stores (
finalBooking↔ passagers du wizard). - Corollaire : les endpoints passent leur body en objet brut — un
body: JSON.stringify(...)fige l'UTC avant la frontière.paymentApi.initializea été corrigé en ce sens (cf.patterns.md→ « Ajouter un endpoint API »).
Alternatives rejetées
- Formatage par-endpoint (statu quo) : a déjà échoué — chaque nouvel endpoint doit y penser, et le plus gros payload de l'app l'avait oublié.
- Override de
Date.prototype.toJSON: global au runtime — toucherait la persistance Pinia (superjson), les logs, toutJSON.stringifynon-wire. - Fix backend (parser l'UTC en local) : hors du contrôle du mobile, et casserait la parité avec le client web qui envoie déjà du sans-fuseau.
Périmètre
Serialize-only. La revival inbound (string → Date des réponses) reste ad-hoc dans les stores (customer.ts, bookings.ts) : aucun bug connu, et un reviver centralisé changerait le type runtime de champs consommés comme strings (occurrence.start/end) dans toute l'app — blast radius injustifié.
Conséquences
- Le contrat wire est verrouillé par les 3 specs wizard (assertions birthdate exactes sur la matrice invité/connecté × one-day/multi-day/seaside + vol-sec, sous le
timezoneId: 'Europe/Zurich'épinglé) et parutils/__tests__/date-formatters.test.ts. booking.general.bookingDatepart désormais en heure murale suisse sans fuseau (avant : instant UTC) — cohérent avec le stockage Kind-naïf d'Horizon (qui timestamppe lui-même enDateTime.Nowlocal).- Voir
DETTE_TECHNIQUE.md→ « La chaîne backend ne reçoit pas les ISO zonées en sécurité » pour la contrainte côté Horizon.

