From 3d86330894b6b103fad1d0aee58af3306a314a9b Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 14 Jul 2026 18:09:47 +0200 Subject: [PATCH 1/7] feat: list upcoming events with a create button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list page now parses the calendar feed and renders each upcoming event (title, date, place) in chronological order, with a button that hands the event over to the town hall form. Sorting, date formatting and the café fallback live in a pure presentation module rather than in liste.js, which touches the DOM and cannot be imported under bun test. The seam to the sibling task's content script lives in transfert.js: a single storage slot, since a content script running on the town hall site has no way to learn a uid. The deposit is awaited before opening the tab, otherwise the content script could read before the write lands. Test timezone is pinned to Europe/Paris: date formatting is the core logic here and would otherwise pass only on machines set to Paris. Co-Authored-By: Claude Opus 4.8 --- extension.test.ts | 4 ++ extension/liste.css | 31 +++++++++ extension/liste.js | 70 ++++++++++++++++--- extension/manifest.json | 1 + extension/presentation.js | 42 +++++++++++ extension/transfert.js | 35 ++++++++++ package.json | 2 +- presentation.test.ts | 143 ++++++++++++++++++++++++++++++++++++++ transfert.test.ts | 86 +++++++++++++++++++++++ 9 files changed, 405 insertions(+), 9 deletions(-) create mode 100644 extension/presentation.js create mode 100644 extension/transfert.js create mode 100644 presentation.test.ts create mode 100644 transfert.test.ts diff --git a/extension.test.ts b/extension.test.ts index 9dd970d..3b38d74 100644 --- a/extension.test.ts +++ b/extension.test.ts @@ -21,6 +21,10 @@ describe("manifest.json", () => { ]); }); + test("demande la permission storage (couture liste → formulaire)", () => { + expect(manifest.permissions).toContain("storage"); + }); + test("ne définit PAS default_popup (sinon onClicked devient muet)", () => { expect(manifest.action.default_popup).toBeUndefined(); }); diff --git a/extension/liste.css b/extension/liste.css index a2d5c68..580fb26 100644 --- a/extension/liste.css +++ b/extension/liste.css @@ -28,6 +28,37 @@ h1 { font-style: italic; } +.evenement { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; + border-top: 1px solid #8884; +} + +.evenement-infos > * { + margin: 0; +} + +.evenement-titre { + font-size: 1rem; +} + +.evenement-date { + margin-top: 0.25rem; +} + +.evenement-lieu { + color: #666; +} + +.evenement-creer { + flex-shrink: 0; + font: inherit; + cursor: pointer; +} + #etat { margin-bottom: 1.5rem; } diff --git a/extension/liste.js b/extension/liste.js index 82d662b..6f11099 100644 --- a/extension/liste.js +++ b/extension/liste.js @@ -1,10 +1,13 @@ -import { - compterEvenements, - recupererFluxCalendrier, -} from "./nextcloud.js"; +import { recupererFluxCalendrier } from "./nextcloud.js"; +import { parserEvenements } from "./evenements.js"; +import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js"; +import { deposerEvenement } from "./transfert.js"; const URL_NEXTCLOUD = "https://atelier-huit.frama.space/"; +const URL_FORMULAIRE_MAIRIE = + "https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/"; + // Mapping type d'erreur → message FR non technique et actionnable. const MESSAGES_ERREUR = { "non-connecte": @@ -16,6 +19,7 @@ const MESSAGES_ERREUR = { }; const etat = document.querySelector("#etat"); +const liste = document.querySelector("#liste"); function rendreNeutre(texte) { etat.replaceChildren(); @@ -25,9 +29,59 @@ function rendreNeutre(texte) { etat.append(p); } -function rendreSucces(flux) { - const nombre = compterEvenements(flux); - rendreNeutre(`${nombre} événement${nombre > 1 ? "s" : ""} lu${nombre > 1 ? "s" : ""}`); +function rendreEvenements(evenements) { + // La liste est sa propre preuve de vie : plus de compteur dans #etat. + etat.replaceChildren(); + liste.replaceChildren(); + + if (evenements.length === 0) { + const vide = document.createElement("li"); + vide.className = "liste-vide"; + vide.textContent = "Aucun événement à venir"; + liste.append(vide); + return; + } + + liste.append(...evenements.map(rendreEvenement)); +} + +// textContent exclusivement : les titres viennent d'une source externe. +function rendreEvenement(evenement) { + const item = document.createElement("li"); + item.className = "evenement"; + + const infos = document.createElement("div"); + infos.className = "evenement-infos"; + + const titre = document.createElement("h2"); + titre.className = "evenement-titre"; + titre.textContent = evenement.titre; + + const date = document.createElement("p"); + date.className = "evenement-date"; + date.textContent = formaterDate(evenement); + + const lieu = document.createElement("p"); + lieu.className = "evenement-lieu"; + lieu.textContent = formaterLieu(evenement); + + infos.append(titre, date, lieu); + + const creer = document.createElement("button"); + creer.type = "button"; + creer.className = "evenement-creer"; + creer.textContent = "Créer sur le site mairie"; + creer.addEventListener("click", () => creerSurSiteMairie(evenement)); + + item.append(infos, creer); + return item; +} + +async function creerSurSiteMairie(evenement) { + // Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant + // l'écriture. + await deposerEvenement(browser.storage.local, evenement); + await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE }); } function rendreErreur(typeErreur) { @@ -60,7 +114,7 @@ async function charger() { // « Illegal invocation » dans le navigateur. const res = await recupererFluxCalendrier((u, o) => fetch(u, o)); if (res.ok) { - rendreSucces(res.flux); + rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date()))); } else { rendreErreur(res.erreur); } diff --git a/extension/manifest.json b/extension/manifest.json index 8c45091..b5c32e7 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -16,6 +16,7 @@ "default_title": "Ouvrir la liste de l'Écho du Huit", "default_icon": "icons/icon.svg" }, + "permissions": ["storage"], "host_permissions": [ "https://atelier-huit.frama.space/*", "https://www.ville-cugnaux.fr/*" diff --git a/extension/presentation.js b/extension/presentation.js new file mode 100644 index 0000000..4c2e0c4 --- /dev/null +++ b/extension/presentation.js @@ -0,0 +1,42 @@ +// Présentation des Event pour la liste : ordre, formatage date, défaut lieu. +// Fonctions pures, sans DOM (testables). Locale et fuseau sont des constantes : +// l'appli est mono-locale (bénévoles de Cugnaux). + +const LOCALE = "fr-FR"; +const FUSEAU = "Europe/Paris"; + +// RECHERCHE.md §2 : adresse du café de l'Atelier du Huit. +export const LIEU_PAR_DEFAUT = "8 Rue du Pré Vicinal 31270 Cugnaux"; + +// Un événement horodaté porte un vrai instant : on l'affiche à l'heure de +// Cugnaux, quel que soit le fuseau de la machine. +const FORMAT_AVEC_HEURE = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + dateStyle: "full", + timeStyle: "short", +}); + +// Sans timeZone, DÉLIBÉRÉMENT (ne pas « rétablir » FUSEAU ici) : une journée +// entière est une date *flottante* (RFC 5545), sans fuseau. 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 9 depuis Tokyo. +const FORMAT_SANS_HEURE = new Intl.DateTimeFormat(LOCALE, { + dateStyle: "full", +}); + +// Le parseur rend l'ordre du flux : l'ordre chronologique se décide ici. +export function trierParDebut(evenements) { + return [...evenements].sort((a, b) => a.debut.getTime() - b.debut.getTime()); +} + +// Une journée entière n'a pas d'heure : afficher « 00:00 » serait un mensonge. +export function formaterDate(evenement) { + const format = evenement.journeeEntiere ? FORMAT_SANS_HEURE : FORMAT_AVEC_HEURE; + return format.format(evenement.debut); +} + +// La plupart des événements se tiennent au café et n'ont pas de LOCATION : +// afficher l'adresse par défaut plutôt qu'une ligne vide. +export function formaterLieu(evenement) { + return evenement.lieu.trim() || LIEU_PAR_DEFAUT; +} diff --git a/extension/transfert.js b/extension/transfert.js new file mode 100644 index 0000000..a184a0d --- /dev/null +++ b/extension/transfert.js @@ -0,0 +1,35 @@ +// Couture liste → content script du formulaire mairie (contrat lu par la tâche +// sœur « formater les données pour le formulaire mairie »). +// +// Clé UNIQUE, pas indexée par uid : le content script s'exécute sur le site de +// la mairie et n'a aucun moyen d'apprendre un uid (le fragment d'URL a été +// écarté). Une clé connue d'avance lui suffit donc à lire « l'événement à +// créer ». Corollaire assumé : le dernier clic gagne, et on n'efface pas à la +// lecture pour que le formulaire survive à un rechargement. +// +// `debut`/`fin` sont sérialisés en ISO 8601 : le contrat reste explicite quel +// que soit le moteur, et le content script réhydrate avec `new Date(iso)`. + +export const CLE_EVENEMENT_EN_ATTENTE = "evenement-en-attente"; + +// `lieu` est laissé brut ("" si absent) : c'est le signal dont le content +// script a besoin pour décider s'il pose aussi les coordonnées du café. +export function serialiserPourFormulaire(evenement) { + return { + uid: evenement.uid, + titre: evenement.titre, + description: evenement.description, + lieu: evenement.lieu, + debut: evenement.debut.toISOString(), + fin: evenement.fin.toISOString(), + categories: evenement.categories, + journeeEntiere: evenement.journeeEntiere, + }; +} + +// `storage` est injecté (obligatoire) : testable sans navigateur. +export async function deposerEvenement(storage, evenement) { + await storage.set({ + [CLE_EVENEMENT_EN_ATTENTE]: serialiserPourFormulaire(evenement), + }); +} diff --git a/package.json b/package.json index 9ff5302..8ae6617 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "type": "module", "description": "Outillage de dev (Bun) pour l'extension. L'extension elle-même est du JS vanilla sans build.", "scripts": { - "test": "bun test" + "test": "TZ=Europe/Paris bun test" } } diff --git a/presentation.test.ts b/presentation.test.ts new file mode 100644 index 0000000..7d691b7 --- /dev/null +++ b/presentation.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + LIEU_PAR_DEFAUT, + formaterDate, + formaterLieu, + trierParDebut, +} from "./extension/presentation.js"; + +// Fixtures = Event littéraux : le parsing est testé dans evenements.test.ts. +function evenement(surcharges: Record = {}) { + return { + uid: "uid-1", + titre: "Atelier", + description: "", + lieu: "", + debut: new Date("2026-07-10T16:00:00Z"), + fin: new Date("2026-07-10T18:00:00Z"), + categories: [], + journeeEntiere: false, + ...surcharges, + }; +} + +describe("trierParDebut", () => { + test("ordre mêlé → croissant sur debut", () => { + const tardif = evenement({ uid: "tardif", debut: new Date("2026-08-01T10:00:00Z") }); + const tot = evenement({ uid: "tot", debut: new Date("2026-07-01T10:00:00Z") }); + const median = evenement({ uid: "median", debut: new Date("2026-07-15T10:00:00Z") }); + + const tries = trierParDebut([tardif, tot, median]); + + expect(tries.map((e) => e.uid)).toEqual(["tot", "median", "tardif"]); + }); + + test("ne mute pas le tableau d'entrée", () => { + const tardif = evenement({ uid: "tardif", debut: new Date("2026-08-01T10:00:00Z") }); + const tot = evenement({ uid: "tot", debut: new Date("2026-07-01T10:00:00Z") }); + const entree = [tardif, tot]; + + trierParDebut(entree); + + expect(entree.map((e) => e.uid)).toEqual(["tardif", "tot"]); + }); + + test("[] → []", () => { + expect(trierParDebut([])).toEqual([]); + }); + + test("deux debut identiques → 2 éléments, pas de plantage", () => { + const meme = new Date("2026-07-10T16:00:00Z"); + const a = evenement({ uid: "a", debut: meme }); + const b = evenement({ uid: "b", debut: meme }); + + expect(trierParDebut([a, b])).toHaveLength(2); + }); +}); + +// Fixture UNIQUE de journée entière, partagée par le test en processus et ceux +// en sous-processus : ils doivent décrire le même Event, sinon l'un des deux +// ment. Composantes LOCALES, jamais un instant absolu ("...T00:00+02:00") : une +// date flottante (VALUE=DATE) n'a pas de fuseau et ical.js la matérialise à +// minuit local, donc un instant figé ne serait juste qu'à +02:00. +const JOURNEE_ENTIERE = [2026, 6, 10] as const; +const JOUR_ATTENDU = "10 juillet 2026"; + +function evenementJourneeEntiere() { + return evenement({ debut: new Date(...JOURNEE_ENTIERE), journeeEntiere: true }); +} + +describe("formaterDate", () => { + test("événement horodaté → date et heure", () => { + const rendu = formaterDate(evenement({ debut: new Date("2026-07-10T16:00:00Z") })); + + expect(rendu).toContain("10 juillet 2026"); + expect(rendu).toContain("18:00"); + }); + + test("journée entière → date sans aucune heure", () => { + const rendu = formaterDate(evenementJourneeEntiere()); + + expect(rendu).toContain(JOUR_ATTENDU); + expect(rendu).not.toMatch(/\d{1,2}:\d{2}/); + }); + + test("instant à 11:00 UTC → affiché à l'heure de Paris (13:00)", () => { + const rendu = formaterDate(evenement({ debut: new Date("2026-07-10T11:00:00Z") })); + + expect(rendu).toContain("13:00"); + }); + + // Le fuseau des tests est épinglé à Europe/Paris : c'est justement le seul où + // une journée entière mal formatée reste invisible. On sous-traite donc à des + // processus réglés ailleurs. + 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); + }); + }, + ); +}); + +// Le fixture traverse la frontière de processus en code source : c'est la même +// constante JOURNEE_ENTIERE, donc les deux tests ne peuvent pas diverger. +async function formaterDansLeFuseau(fuseau: string): Promise { + const module = JSON.stringify(join(import.meta.dir, "extension/presentation.js")); + const processus = Bun.spawn( + [ + "bun", + "-e", + `import { formaterDate } from ${module}; + console.log(formaterDate({ + debut: new Date(${JOURNEE_ENTIERE.join(", ")}), + journeeEntiere: true, + }));`, + ], + { env: { ...process.env, TZ: fuseau }, stdout: "pipe", stderr: "pipe" }, + ); + + const [sortie, erreur, code] = await Promise.all([ + new Response(processus.stdout).text(), + new Response(processus.stderr).text(), + processus.exited, + ]); + if (code !== 0) throw new Error(`bun -e a échoué (${code}) : ${erreur}`); + return sortie; +} + +describe("formaterLieu", () => { + test("lieu renseigné → rendu tel quel", () => { + expect(formaterLieu(evenement({ lieu: "Salle des fêtes" }))).toBe("Salle des fêtes"); + }); + + test("lieu vide → adresse du café", () => { + expect(formaterLieu(evenement({ lieu: "" }))).toBe(LIEU_PAR_DEFAUT); + }); + + test("lieu en blancs → adresse du café", () => { + expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT); + }); +}); diff --git a/transfert.test.ts b/transfert.test.ts new file mode 100644 index 0000000..a98aac6 --- /dev/null +++ b/transfert.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { + CLE_EVENEMENT_EN_ATTENTE, + deposerEvenement, + serialiserPourFormulaire, +} from "./extension/transfert.js"; + +function evenement(surcharges: Record = {}) { + return { + uid: "uid-1", + titre: "Atelier", + description: "Venez nombreux", + lieu: "Salle des fêtes", + debut: new Date("2026-07-10T16:00:00Z"), + fin: new Date("2026-07-10T18:00:00Z"), + categories: ["soumis"], + journeeEntiere: false, + ...surcharges, + }; +} + +// Storage factice (Chicago) : capture ce qui est écrit, pas de mock lourd. +function storageFactice() { + const ecrit: Record = {}; + return { + ecrit, + set: async (objet: Record) => Object.assign(ecrit, objet), + }; +} + +describe("serialiserPourFormulaire", () => { + test("debut et fin deviennent des chaînes ISO", () => { + const payload = serialiserPourFormulaire(evenement()); + + expect(payload.debut).toBe("2026-07-10T16:00:00.000Z"); + expect(payload.fin).toBe("2026-07-10T18:00:00.000Z"); + }); + + test("un lieu vide reste vide (pas de défaut café — D4)", () => { + const payload = serialiserPourFormulaire(evenement({ lieu: "" })); + + expect(payload.lieu).toBe(""); + }); + + test("porte tous les champs du contrat Event", () => { + const payload = serialiserPourFormulaire(evenement()); + + expect(payload).toEqual({ + uid: "uid-1", + titre: "Atelier", + description: "Venez nombreux", + lieu: "Salle des fêtes", + debut: "2026-07-10T16:00:00.000Z", + fin: "2026-07-10T18:00:00.000Z", + categories: ["soumis"], + journeeEntiere: false, + }); + }); + + test("aller-retour : le content script peut réhydrater debut", () => { + const ev = evenement(); + const payload = serialiserPourFormulaire(ev); + + expect(new Date(payload.debut).getTime()).toBe(ev.debut.getTime()); + }); +}); + +describe("deposerEvenement", () => { + test("écrit sous la clé evenement-en-attente", async () => { + const storage = storageFactice(); + + await deposerEvenement(storage, evenement()); + + expect(storage.ecrit[CLE_EVENEMENT_EN_ATTENTE]).toMatchObject({ uid: "uid-1" }); + }); + + test("un second dépôt écrase le premier (slot unique — D2)", async () => { + const storage = storageFactice(); + + await deposerEvenement(storage, evenement({ uid: "premier" })); + await deposerEvenement(storage, evenement({ uid: "second" })); + + expect(Object.keys(storage.ecrit)).toEqual([CLE_EVENEMENT_EN_ATTENTE]); + expect(storage.ecrit[CLE_EVENEMENT_EN_ATTENTE]).toMatchObject({ uid: "second" }); + }); +}); From 532c17747fb556f0a2f38d6aac0bf18860d886f8 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 14 Jul 2026 18:09:47 +0200 Subject: [PATCH 2/7] refactor: drop the now dead compterEvenements helper It existed as T1's proof of life; the rendered list proves it better. liste.js was its only caller and no longer counts VEVENTs. Co-Authored-By: Claude Opus 4.8 --- extension/nextcloud.js | 6 ------ nextcloud.test.ts | 19 ------------------- 2 files changed, 25 deletions(-) diff --git a/extension/nextcloud.js b/extension/nextcloud.js index 37c5087..a55a606 100644 --- a/extension/nextcloud.js +++ b/extension/nextcloud.js @@ -30,9 +30,3 @@ export async function recupererFluxCalendrier(fetchImpl) { } return { ok: true, flux }; } - -// Preuve de vie de T1 : compte les vrais débuts de VEVENT (ancrage ligne, pas -// une chaîne « BEGIN:VEVENT » apparaissant dans une DESCRIPTION). -export function compterEvenements(flux) { - return (flux.match(/^BEGIN:VEVENT\s*$/gm) ?? []).length; -} diff --git a/nextcloud.test.ts b/nextcloud.test.ts index d952c61..697b255 100644 --- a/nextcloud.test.ts +++ b/nextcloud.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { URL_CALENDRIER, - compterEvenements, recupererFluxCalendrier, } from "./extension/nextcloud.js"; @@ -65,21 +64,3 @@ describe("recupererFluxCalendrier", () => { expect(optionsVues).toEqual({ credentials: "include" }); }); }); - -describe("compterEvenements", () => { - test("compte 2 VEVENT", () => { - const flux = - "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; - expect(compterEvenements(flux)).toBe(2); - }); - - test("compte 0 quand aucun VEVENT", () => { - expect(compterEvenements("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")).toBe(0); - }); - - test("ignore un BEGIN:VEVENT au milieu d'une DESCRIPTION", () => { - const flux = - "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDESCRIPTION:texte BEGIN:VEVENT piège\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; - expect(compterEvenements(flux)).toBe(1); - }); -}); From f994bfac30f4446bc95fa6f95861a05e1dfdf40d Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 14 Jul 2026 18:09:47 +0200 Subject: [PATCH 3/7] docs: record the list to content script seam (T1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the single storage key (revising the sibling task's "key = uid" seam, which had no way to transport the uid), the explicit ISO serialisation, and why the café fallback is display-only. Co-Authored-By: Claude Opus 4.8 --- docs/DECISIONS.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e7cb2ea..dfa83f3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -70,3 +70,49 @@ ici. Gravity Forms n'accepte `?input_X=` que si chaque champ est configuré « population dynamique » côté mairie (improbable). On remplit donc le **DOM par id** (ids confirmés dans `RECHERCHE.md`). + +## 2026-07-14 — [T1] Liste : couture page → content script + +La liste dépose l'événement choisi dans `storage.local` **puis** ouvre le +formulaire mairie (`extension/transfert.js`). Ce que la tâche sœur +(« formater les données pour le formulaire mairie ») doit lire : + +**Clé unique `evenement-en-attente`, PAS `evenement:`** (révise la couture +« clé = uid » annoncée par la tâche sœur, qui avait un trou). Le content script +s'exécute sur le site de la mairie : il n'a **aucun canal** pour apprendre un +`uid`, le fragment d'URL ayant été écarté (pas de pollution d'une URL tierce). +Une clé connue d'avance le rend lisible sans transporter quoi que ce soit. +- Pas de résidus : un seul slot, écrasé au clic suivant (le dernier clic gagne — + limite assumée, le geste réel est séquentiel et le formulaire est modéré). +- **On n'efface pas à la lecture** : le formulaire survit à un rechargement. +- Le dépôt est `await` **avant** `tabs.create` : sinon le content script peut + lire avant l'écriture. + +**`debut`/`fin` sérialisés en ISO 8601** (le payload n'est pas un `Event`). On ne +parie pas sur le structured clone : le contrat reste vrai quel que soit le +moteur, le content script réhydrate avec `new Date(iso)`. + +⚠️ **Si `journeeEntiere` est vrai, l'instant ISO n'est PAS significatif.** Une +`DTSTART;VALUE=DATE` est une date *flottante* (RFC 5545) : elle n'a pas de +fuseau. ical.js la matérialise à **minuit local**, donc l'ISO produit dépend de +la machine (`2026-07-10` devient `...T15:00:00Z` depuis Tokyo). Le consommateur +doit **tester `journeeEntiere` d'abord** et n'en lire que les composantes +**locales** (`getFullYear`/`getMonth`/`getDate`) — jamais l'heure, jamais une +conversion de fuseau. C'est pour la même raison que la liste formate les +journées entières **sans** `timeZone` (`presentation.js`) : forcer `Europe/Paris` +faisait glisser la date au jour précédent depuis Tokyo ou Auckland. + +**`TZ=Europe/Paris` sur `bun test` est PORTEUR — ne pas le retirer.** Le runner +de Bun force **`TZ=UTC`** quand `TZ` est absente (vérifié : `bun -e` lit le +fuseau système, le runner non). Sans le pin, les tests ne tournent donc pas dans +le fuseau des bénévoles. Corollaire pour l'écriture des tests : un fixture de +date flottante (journée entière, `DTSTART` sans `Z` ni `TZID`) se construit en +**composantes locales** (`new Date(2026, 6, 10)`), jamais en instant absolu +(`new Date("2026-07-10T00:00:00+02:00")`) — ce dernier n'est juste que sur une +machine à +02:00 et ment partout ailleurs. + +**Défaut café = affichage seulement ; `lieu` stocké brut** (`""` si absent). Les +deux avals du parseur ont chacun le leur : la liste affiche l'adresse pour ne pas +montrer une ligne vide (cas majoritaire), le content script décide s'il pose +**aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload +détruirait le signal « pas de lieu » dont il a besoin. From 596b5e22fdf801e7c66a4d07b1601a2fb8cb1bdb Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 11:55:23 +0200 Subject: [PATCH 4/7] feat: show the event time range, not just its start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pierre's field test: the agenda announces "10:30 → 12:30", the list only said "à 10:30". The end date was already parsed and already in the payload; it was purely missing from the display. Naively rendering start → end would have been wrong four ways, all measured against the real parser: - no DTEND: the parser sets fin === debut, so "de 10:30 à 10:30" - crossing midnight: "samedi de 22:00 à 02:00" hides the end date - all-day: DTEND is EXCLUSIVE (RFC 5545), so a one-day event carries the next day, and a 18→20 festival carries the 21st The last one is the dangerous one: it yields a wrong date, not just an ugly one. The end day is therefore fin minus one day, computed from local components since a floating date has no timezone. DECISIONS.md records the exclusive DTEND for the sibling task, which fills the town hall's end date field straight from DTEND. Co-Authored-By: Claude Opus 4.8 --- docs/DECISIONS.md | 10 +++++ extension/presentation.js | 48 ++++++++++++++++++++++-- presentation.test.ts | 78 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index dfa83f3..a68cf6e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -102,6 +102,16 @@ conversion de fuseau. C'est pour la même raison que la liste formate les journées entières **sans** `timeZone` (`presentation.js`) : forcer `Europe/Paris` faisait glisser la date au jour précédent depuis Tokyo ou Auckland. +⚠️ **`fin` est un DTEND EXCLUSIF (RFC 5545) — ne pas l'afficher/soumettre tel quel.** +Le payload porte la valeur brute du parseur. Pour une **journée entière**, un +événement du 18 porte `fin` = le **19**, et un festival du 18 au 20 porte `fin` = +le **21** : le dernier jour réel est **`fin` moins un jour** (composantes locales, +jamais « -24h »). Concerne directement la tâche sœur, qui remplit `#input_6_32` +(Date fin) **depuis DTEND** : sans ce recul d'un jour, elle soumettra une date +**fausse** à la mairie. La liste applique déjà la règle (`presentation.js`, +`veilleDe`). Autre cas mesuré : **`DTEND` absent → le parseur pose `fin === debut`** +(il n'y a alors pas de plage à afficher, ni de fin à soumettre). + **`TZ=Europe/Paris` sur `bun test` est PORTEUR — ne pas le retirer.** Le runner de Bun force **`TZ=UTC`** quand `TZ` est absente (vérifié : `bun -e` lit le fuseau système, le runner non). Sans le pin, les tests ne tournent donc pas dans diff --git a/extension/presentation.js b/extension/presentation.js index 4c2e0c4..b357d53 100644 --- a/extension/presentation.js +++ b/extension/presentation.js @@ -10,9 +10,12 @@ export const LIEU_PAR_DEFAUT = "8 Rue du Pré Vicinal 31270 Cugnaux"; // Un événement horodaté porte un vrai instant : on l'affiche à l'heure de // Cugnaux, quel que soit le fuseau de la machine. -const FORMAT_AVEC_HEURE = new Intl.DateTimeFormat(LOCALE, { +const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat(LOCALE, { timeZone: FUSEAU, dateStyle: "full", +}); +const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, timeStyle: "short", }); @@ -20,7 +23,7 @@ const FORMAT_AVEC_HEURE = new Intl.DateTimeFormat(LOCALE, { // entière est une date *flottante* (RFC 5545), sans fuseau. 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 9 depuis Tokyo. -const FORMAT_SANS_HEURE = new Intl.DateTimeFormat(LOCALE, { +const FORMAT_JOUR_FLOTTANT = new Intl.DateTimeFormat(LOCALE, { dateStyle: "full", }); @@ -31,8 +34,45 @@ export function trierParDebut(evenements) { // Une journée entière n'a pas d'heure : afficher « 00:00 » serait un mensonge. export function formaterDate(evenement) { - const format = evenement.journeeEntiere ? FORMAT_SANS_HEURE : FORMAT_AVEC_HEURE; - return format.format(evenement.debut); + return evenement.journeeEntiere + ? formaterJourneeEntiere(evenement) + : formaterHorodate(evenement); +} + +function formaterJourneeEntiere({ debut, fin }) { + const premierJour = FORMAT_JOUR_FLOTTANT.format(debut); + const dernierJour = veilleDe(fin); + + // Un seul jour, ou pas de DTEND exploitable (le parseur pose alors fin === debut). + if (dernierJour.getTime() <= debut.getTime()) return premierJour; + + return `du ${premierJour} au ${FORMAT_JOUR_FLOTTANT.format(dernierJour)}`; +} + +// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19, un festival +// du 18 au 20 porte DTEND=21. Sans ce recul d'un jour, on annoncerait un jour de +// trop — une date FAUSSE, pas seulement laide. +// Composantes locales (jamais « -24h ») : la date est flottante, et un +// changement d'heure ferait basculer le jour. +function veilleDe(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1); +} + +function formaterHorodate({ debut, fin }) { + const jourDebut = FORMAT_JOUR_PARIS.format(debut); + const heureDebut = FORMAT_HEURE_PARIS.format(debut); + + // DTEND absent : le parseur pose fin === debut. « de 10:30 à 10:30 » serait absurde. + if (fin.getTime() <= debut.getTime()) return `${jourDebut} à ${heureDebut}`; + + const jourFin = FORMAT_JOUR_PARIS.format(fin); + const heureFin = FORMAT_HEURE_PARIS.format(fin); + + // Comparaison des jours tels qu'AFFICHÉS (donc à Paris) : un événement de + // 22:00 à 02:00 change de jour, l'annoncer sur un seul serait un mensonge. + if (jourFin === jourDebut) return `${jourDebut} de ${heureDebut} à ${heureFin}`; + + return `du ${jourDebut} à ${heureDebut} au ${jourFin} à ${heureFin}`; } // La plupart des événements se tiennent au café et n'ont pas de LOCATION : diff --git a/presentation.test.ts b/presentation.test.ts index 7d691b7..409ed95 100644 --- a/presentation.test.ts +++ b/presentation.test.ts @@ -61,11 +61,17 @@ describe("trierParDebut", () => { // ment. Composantes LOCALES, jamais un instant absolu ("...T00:00+02:00") : une // date flottante (VALUE=DATE) n'a pas de fuseau et ical.js la matérialise à // minuit local, donc un instant figé ne serait juste qu'à +02:00. -const JOURNEE_ENTIERE = [2026, 6, 10] as const; +const JOURNEE_ENTIERE_DEBUT = [2026, 6, 10] as const; +// DTEND est EXCLUSIF (RFC 5545) : le 11 signifie « dernier jour réel = le 10 ». +const JOURNEE_ENTIERE_FIN = [2026, 6, 11] as const; const JOUR_ATTENDU = "10 juillet 2026"; function evenementJourneeEntiere() { - return evenement({ debut: new Date(...JOURNEE_ENTIERE), journeeEntiere: true }); + return evenement({ + debut: new Date(...JOURNEE_ENTIERE_DEBUT), + fin: new Date(...JOURNEE_ENTIERE_FIN), + journeeEntiere: true, + }); } describe("formaterDate", () => { @@ -89,6 +95,71 @@ describe("formaterDate", () => { expect(rendu).toContain("13:00"); }); + // Les 6 formes de fin mesurées sur le vrai parseur (cf. rapport de test). + // Cas de Pierre : 10:30 → 12:30 le 18/07 (10:30 Paris = 08:30Z). + describe("plage horaire", () => { + test("début et fin le même jour → « de 10:30 à 12:30 »", () => { + const rendu = formaterDate( + evenement({ + debut: new Date("2026-07-18T08:30:00Z"), + fin: new Date("2026-07-18T10:30:00Z"), + }), + ); + + expect(rendu).toContain("18 juillet 2026"); + expect(rendu).toContain("10:30"); + expect(rendu).toContain("12:30"); + }); + + // DTEND absent : le parseur pose fin === debut. « de 10:30 à 10:30 » serait absurde. + test("sans fin (fin === debut) → une seule heure affichée", () => { + const debut = new Date("2026-07-18T08:30:00Z"); + const rendu = formaterDate(evenement({ debut, fin: new Date(debut) })); + + expect(rendu).toContain("10:30"); + expect(rendu.match(/\d{1,2}:\d{2}/g)).toHaveLength(1); + }); + + // 22:00 → 02:00 : « samedi 18 de 22:00 à 02:00 » serait un mensonge. + test("franchit minuit → la date de fin est affichée", () => { + const rendu = formaterDate( + evenement({ + debut: new Date("2026-07-18T20:00:00Z"), + fin: new Date("2026-07-19T00:00:00Z"), + }), + ); + + expect(rendu).toContain("18 juillet 2026"); + expect(rendu).toContain("22:00"); + expect(rendu).toContain("19 juillet 2026"); + expect(rendu).toContain("02:00"); + }); + + test("journée entière d'un jour (DTEND exclusif) → le seul jour réel", () => { + const rendu = formaterDate(evenementJourneeEntiere()); + + expect(rendu).toContain(JOUR_ATTENDU); + expect(rendu).not.toContain("11 juillet"); // le DTEND exclusif n'est pas un jour de l'événement + expect(rendu).not.toMatch(/\d{1,2}:\d{2}/); + }); + + // Festival 18→21 exclusif : il se termine réellement le 20. + test("journée entière sur plusieurs jours → dernier jour réel = DTEND - 1", () => { + const rendu = formaterDate( + evenement({ + debut: new Date(2026, 6, 18), + fin: new Date(2026, 6, 21), + journeeEntiere: true, + }), + ); + + expect(rendu).toContain("18 juillet 2026"); + expect(rendu).toContain("20 juillet 2026"); + expect(rendu).not.toContain("21 juillet"); + expect(rendu).not.toMatch(/\d{1,2}:\d{2}/); + }); + }); + // Le fuseau des tests est épinglé à Europe/Paris : c'est justement le seul où // une journée entière mal formatée reste invisible. On sous-traite donc à des // processus réglés ailleurs. @@ -112,7 +183,8 @@ async function formaterDansLeFuseau(fuseau: string): Promise { "-e", `import { formaterDate } from ${module}; console.log(formaterDate({ - debut: new Date(${JOURNEE_ENTIERE.join(", ")}), + debut: new Date(${JOURNEE_ENTIERE_DEBUT.join(", ")}), + fin: new Date(${JOURNEE_ENTIERE_FIN.join(", ")}), journeeEntiere: true, }));`, ], From 08a8cc83d35373841b99d5deae5aeb65191a3fe6 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 11:55:23 +0200 Subject: [PATCH 5/7] fix: surface failures of the create button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pierre reported the button doing nothing. The click handler dropped the async promise, so any failure became an unhandled rejection: no tab, no message, nothing to diagnose from — whatever the underlying cause. T1 deliberately shipped no error UI, but the field showed that choice makes the anomaly both invisible and undiagnosable. The failure is now caught, logged, and named on screen, and the advice to reload covers the orphaned page case (extension reloaded while the list stayed open). Co-Authored-By: Claude Opus 4.8 --- extension/liste.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/extension/liste.js b/extension/liste.js index 6f11099..cc01247 100644 --- a/extension/liste.js +++ b/extension/liste.js @@ -71,7 +71,13 @@ function rendreEvenement(evenement) { creer.type = "button"; creer.className = "evenement-creer"; creer.textContent = "Créer sur le site mairie"; - creer.addEventListener("click", () => creerSurSiteMairie(evenement)); + creer.addEventListener("click", async () => { + try { + await creerSurSiteMairie(evenement); + } catch (erreur) { + rendreEchecCreation(erreur); + } + }); item.append(infos, creer); return item; @@ -84,6 +90,21 @@ async function creerSurSiteMairie(evenement) { await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE }); } +// Sans ce retour, la promesse du clic échouait en silence : le bouton paraissait +// mort et la panne était indiagnosticable, y compris pour nous. Le détail +// technique est affiché volontairement — c'est ce qui permet de nommer la cause +// sans ouvrir la console. Le F5 couvre le cas de la page orpheline (extension +// rechargée dans about:debugging alors que la liste restait ouverte). +function rendreEchecCreation(erreur) { + console.error("Échec de l'ouverture du formulaire mairie", erreur); + + etat.replaceChildren(); + const message = document.createElement("p"); + message.className = "etat-erreur"; + message.textContent = `Impossible d'ouvrir le formulaire mairie : ${erreur.message}. Recharge la page (F5), puis réessaie.`; + etat.append(message); +} + function rendreErreur(typeErreur) { etat.replaceChildren(); From b2361fcfb92f33bc7d8844427dda4bbf23f6d2db Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 12:37:37 +0200 Subject: [PATCH 6/7] docs: capitalize the event list and form seam solution (T1) Co-Authored-By: Claude Opus 4.8 --- ...-selection-des-evenements-oear-solution.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/solutions/2026-07-17-cli-de-selection-des-evenements-oear-solution.md diff --git a/docs/solutions/2026-07-17-cli-de-selection-des-evenements-oear-solution.md b/docs/solutions/2026-07-17-cli-de-selection-des-evenements-oear-solution.md new file mode 100644 index 0000000..f6246c0 --- /dev/null +++ b/docs/solutions/2026-07-17-cli-de-selection-des-evenements-oear-solution.md @@ -0,0 +1,207 @@ +# 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:` (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 : +```js +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 : +```js +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) : +```js +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` : +```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** : +```js +// 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. +```ts +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, `
  • ` **dans** le `
      ` (un état vide est un +contenu, pas une erreur — et un `
        ` ne peut contenir que des `
      • `). + +## 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. 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] From 59715a8d62545fd71932181cf8f03eed49b7f006 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 12:37:37 +0200 Subject: [PATCH 7/7] docs: warn that a manifest change needs a full reinstall Firefox re-reads page scripts on every open but the manifest only at install time. A "Reload" leaves the newest code running with the old permission set, surfacing as `browser. is undefined` despite a correct manifest. This trap cost two test passes on T1; T2 adds a CalDAV permission and would hit it next. Co-Authored-By: Claude Opus 4.8 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index e3c68ac..a7caec3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ runtime à installer) : > ⚠️ Un module temporaire **disparaît au redémarrage de Firefox** : il faut le > recharger via `about:debugging` à chaque session (comportement de dev attendu). +> 🔴 **Après toute modification de `manifest.json` (permission ajoutée…) : +> *Décharger* puis *Charger* le module. « Recharger » et F5 ne suffisent pas.** +> Firefox relit les **scripts** de la page à chaque ouverture, mais le +> **manifest** seulement **à l'installation** : on se retrouve sinon avec le code +> le plus récent et les **permissions figées de l'ancien**. +> Symptôme : `browser. is undefined` alors que le manifest déclare bien la +> permission. Piège de **dev uniquement** (en module signé, les permissions +> déclarées sont accordées d'office) — mais il a déjà coûté deux passes de test. +> +> Pense aussi à **fermer les anciens onglets de la liste** avant de recharger : +> une page orpheline rend les clics inertes. + ### Vérifier l'extension ```sh