Files
echo-du-huit/docs/solutions/2026-07-17-cli-de-selection-des-evenements-oear-solution.md
T

12 KiB

Solution : [T1] Page liste : afficher les événements + bouton Créer

Problème résolu

Câbler le parseur (evenements.js, figé en T1 précédente mais branché nulle part) à la page : afficher les événements futurs triés, et poser la couture page → content script pour le pré-remplissage du formulaire mairie (tâche sœur).

Sous un ticket d'apparence UI se cachaient deux décisions structurantes :

  1. Où vivent le fetch et le parsing ? L'énoncé disait « passage background (CalDAV+parsing) → page », mais le code réel fetchait déjà dans la page. La question n'était donc pas comment transporter, mais faut-il introduire ce transport.
  2. Que transmet le bouton « Créer » ? Le remplissage appartient à la tâche sœur ; T1 devait poser la couture, pas le remplissage.

Le coût d'une erreur ici n'était pas dans T1 mais dans T2 (statuts CalDAV) et dans la tâche content script, qui héritent du chemin de données choisi.

Approche choisie

A + H1 : fetch et parsing restent dans la page ; storage.local sous une clé unique pour la seule frontière irréductible (page → content script).

Pourquoi pas les alternatives :

  • Background fetch+parse par message (B) : introduit une frontière de sérialisation gratuite. Firefox préserve les Date (structured clone), Chrome les JSON-ise : faire reposer le contrat Event fraîchement figé sur un détail du moteur = dette silencieuse. Zéro besoin actuel — background.js garde son unique rôle (ouvrir l'onglet) et n'a pas été modifié.
  • Background → storage.session (C) : le storage JSON-ise → debut/fin deviennent des chaînes, réhydratation partout, contrat entamé. Plus invalidation/fraîcheur à gérer. Sur-ingénierie pour une liste ouverte à la demande.
  • Clé evenement:<uid> (couture annoncée par la tâche sœur) : inutilisable en l'état — le content script s'exécute sur le site mairie et n'a aucun canal pour apprendre un uid (le fragment d'URL a été écarté). La couture nommée par la tâche sœur avait un trou ; T1 l'a révisée (voir DECISIONS.md).
  • tabs.sendMessage au chargement : course page/content script à arbitrer, et un F5 sur le formulaire (modéré, on y revient) perd le contexte.

Le principe : n'introduire une frontière que quand elle sert quelqu'un. Le background reste disponible ; quand T2 aura un vrai besoin d'orchestration, il sera introduit pour une raison, pas par anticipation.

Décisions clés

Actées en détail dans docs/DECISIONS.md (2026-07-14) — à lire avant la tâche sœur ou T2. En résumé :

  • Modules purs extraits (presentation.js, transfert.js) plutôt que tout dans liste.js : ce dernier touche document dès l'import, donc l'importer dans Bun plante — y enterrer tri/formatage/défaut aurait rendu de la logique métier réelle intestable. Chaque module existe parce qu'il rend testable une décision métier (l'ordre, le mensonge « 00:00 », le contrat de couture).
  • Clé unique evenement-en-attente, pas indexée par uid ; on n'efface pas à la lecture (le formulaire survit à un F5) ; le dernier clic gagne (assumé : le geste réel est séquentiel).
  • Sérialisation ISO explicite : on ne parie pas sur le structured clone.
  • Défaut café = affichage seulement, lieu stocké brut ("" si absent) : écrire l'adresse dans le payload détruirait le signal « pas de lieu » dont le content script a besoin pour décider s'il pose aussi les coordonnées.
  • await le dépôt AVANT tabs.create : sinon course — c'est précisément ce que la couture achète (« la donnée attend le content script »).
  • Permission storage seule ; "tabs" correctement écartée (tabs.create n'en a pas besoin — confirmé en Firefox réel).

Patterns à réutiliser

Injecter la dépendance impure, garder le module pur (convention du repo : fetchImpl, aujourdhui, et maintenant storage) — paramètre obligatoire, testable sans navigateur :

export async function deposerEvenement(storage, evenement) {
  await storage.set({ [CLE_EVENEMENT_EN_ATTENTE]: serialiserPourFormulaire(evenement) });
}
// appel réel : deposerEvenement(browser.storage.local, evenement)

Le point d'entrée injecte le « maintenant » — la pureté du parseur est préservée jusqu'au dernier moment :

rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date())));

Tout handler async de clic doit attraper — sinon la promesse rejette en silence et le bouton paraît mort (voir Pièges) :

creer.addEventListener("click", async () => {
  try { await creerSurSiteMairie(evenement); }
  catch (erreur) { rendreEchecCreation(erreur); } // console.error + message NOMMANT l'erreur
});

Closure sur l'événement de la carte : ni dataset, ni re-lookup par uid.

Formater une date flottante SANS timeZone, un instant AVEC — les deux formatteurs coexistent délibérément dans presentation.js :

const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("fr-FR", { timeZone: "Europe/Paris", dateStyle: "full" });
// Sans timeZone, DÉLIBÉRÉMENT : une journée entière est une date *flottante*
// (RFC 5545). ical.js la matérialise à minuit local ; la relire en local
// restitue le bon jour partout, alors que la convertir vers Paris la ferait
// glisser au jour précédent depuis Tokyo.
const FORMAT_JOUR_FLOTTANT = new Intl.DateTimeFormat("fr-FR", { dateStyle: "full" });

Reculer d'un jour un DTEND de journée entière, en composantes locales :

// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19.
// Jamais « -24h » : la date est flottante, un changement d'heure ferait basculer le jour.
const veilleDe = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1);

Tester ce que le fuseau épinglé cache — en sous-processus. TZ=Europe/Paris est justement le seul fuseau où une journée entière mal formatée reste invisible. Intl fige le fuseau à l'import : seul un autre processus peut le changer.

describe.each(["Asia/Tokyo", "Pacific/Auckland", "America/New_York", "Europe/Paris"])(
  "sous TZ=%s", (fuseau) => {
    test("journée entière → la date ne glisse pas d'un jour", async () => {
      expect(await formaterDansLeFuseau(fuseau)).toContain(JOUR_ATTENDU);
    });
  });
// Bun.spawn(["bun", "-e", …], { env: { ...process.env, TZ: fuseau } })

Le fixture traverse la frontière de processus en code source (une constante unique interpolée : JOURNEE_ENTIERE_DEBUT.join(", ")) → les deux tests ne peuvent plus diverger par construction. C'est ce qui a corrigé la cause du faux vert, pas son symptôme.

Assertions tolérantes sur les sorties Intl (toContain / not.toMatch, jamais l'égalité stricte) : la ponctuation d'ICU varie entre versions de Bun. On teste l'intention (bon jour, heure présente/absente, bon fuseau), pas le typographe.

Rendu DOM sûr : textContent exclusivement (jamais innerHTML — les titres viennent d'une source externe), replaceChildren() pour rester rejouable au Réessayer, <li class="liste-vide"> dans le <ul> (un état vide est un contenu, pas une erreur — et un <ul> ne peut contenir que des <li>).

Pièges à éviter

  • 🔴 Date flottante vs instant absolu : la famille de bugs de ce ticket. Une DTSTART;VALUE=DATE n'a pas de fuseau. Cette confusion a produit trois défauts distincts, chacun invisible sous TZ=Europe/Paris :

    1. formaterDate forçait Europe/Paris sur les journées entières → la date glissait d'un jour depuis Tokyo (trou du plan, pas de l'exécution) ;
    2. un fixture de test déclarait un instant absolu ("...T00:00+02:00") pour décrire une date flottante → faux vert ;
    3. (préexistant, hors T1) evenements.test.ts:141 mêle un AUJOURDHUI absolu à un DTSTART flottant → rouge sous Auckland.

    Règle : un fixture de date flottante se construit en composantes locales (new Date(2026, 6, 10)), jamais en instant absolu — ce dernier n'est juste que sur une machine à +02:00 et ment partout ailleurs. Côté consommateur : tester journeeEntiere d'abord, ne lire que les composantes locales.

  • 🔴 Un handler async non attrapé = un bouton silencieusement mort. () => creerSurSiteMairie(evenement) : la promesse n'était ni attendue ni attrapée → toute défaillance devenait un rejet silencieux. Aucun onglet, aucun message, rien à diagnostiquer. Le plan avait différé l'UI d'erreur (« non demandé en T1 ») : le terrain a démontré que ce report rendait l'anomalie invisible ET indiagnosticable, et a coûté deux passes de test. Afficher le détail technique est volontaire : c'est ce qui a fait passer la panne d'« indiagnosticable » à « nommée en 3 secondes ». Leçon générale : la remontée d'erreur d'un geste utilisateur n'est pas du confort différable — c'est l'instrument qui rend le reste diagnosticable.

  • 🔴 Ajouter une clé au manifest.json impose Décharger + Charger. Firefox relit les scripts de la page à chaque ouverture, mais le manifest seulement à l'installation. On se retrouve avec le code le plus récent et les permissions figées de l'ancien. Symptôme : browser.<api> is undefined alors que le manifest déclare la permission. F5 et « Recharger » ne réparent rien. Ce piège a coûté deux passes ; T2 (écriture CalDAV) y retombera en ajoutant sa permission. Documenté dans README.md § Installation (dev).

  • bun test nu force TZ=UTC quand TZ est absente (le runner, pas bun -e qui lit le fuseau système). Sans le pin TZ=Europe/Paris de package.json, les tests ne tournent pas dans le fuseau des bénévoles. Le pin est PORTEUR — ne pas le retirer. Corollaire : un test qui ne passe que grâce au pin cache un bug de fuseau (cf. le sous-processus ci-dessus).

  • Un fuseau épinglé masque autant qu'il stabilise. Europe/Paris est précisément le fuseau où les bugs de date flottante sont invisibles. Épingler pour le déterminisme et tester ailleurs pour la vérité.

  • DTEND exclusif : sans le recul d'un jour, on annonce une date FAUSSE (pas juste laide). ⚠️ La tâche sœur doit l'appliquer avant de remplir #input_6_32, sinon elle soumettra une date fausse à la mairie. Autre cas : DTEND absent → le parseur pose fin === debut (pas de plage à afficher : « de 10:30 à 10:30 » serait absurde).

  • Ne pas coder « debut → fin » naïvement : c'était faux 4 fois sur 6 (DTEND absent, franchit minuit, journée entière, festival). Mesurer les formes réelles sur le vrai parseur avant de coder.

  • La liste est incomplète par construction : les récurrents au DTSTART maître passé (atelier hebdo, permanence) n'y figurent pas — trou hérité, comblé par [T3]. Aucun avertissement UI : un avertissement permanent que personne ne peut lever devient du bruit.

  • Le bouton ouvre un formulaire VIDE tant que la tâche sœur n'est pas faite. État intermédiaire honnête et démontrable (about:debugging → Storage → evenement-en-attente), pas un bug. Ne pas « finir le travail » en douce : cela casserait un découpage déjà tranché.

Tags

tags: [firefox-extension, manifest-v3, webextension, vanilla-js, storage-local, content-script, seam, icalendar, rfc5545, floating-date, dtend-exclusive, timezone, intl, date-formatting, pure-function, dependency-injection, bun-test, cross-timezone-testing, test-fixtures, false-green, async-error-handling, silent-failure, dom-security, textcontent, serialization, contract-design, yagni, low-tech]