Skip to content

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 à Horizon POST /api/bookings (Newtonsoft, DateTimeZoneHandling.RoundtripKind par défaut).
  • Aucun hop ne normalise (ToLocalTime/.Date absents du chemin birthdate) — le TZ Europe/Zurich du serveur n'a aucun effet. La valeur UTC est stockée telle quelle en datetime2 : la birthdate atterrit un jour trop tôt dans Horizon (idem checkIn/checkOut dé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-dd et yyyy-MM-dd'T'HH:mm:ss) sont parsés Kind=Unspecified aux deux hops et conservent la date calendaire de bout en bout. Tout suffixe Z/+hh:mm est 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-fns formatDate(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.
  • Date invalide → null (parité JSON.stringify) ; strings jamais réécrites (les pré-formatages yyyy-MM-dd existants 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.initialize a é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, tout JSON.stringify non-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 par utils/__tests__/date-formatters.test.ts.
  • booking.general.bookingDate part 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 en DateTime.Now local).
  • 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.

Contributors

No contributors

Changelog

No recent changes