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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e7cb2ea..a68cf6e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -70,3 +70,59 @@ 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. + +⚠️ **`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 +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. 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] 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..cc01247 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,80 @@ 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", async () => { + try { + await creerSurSiteMairie(evenement); + } catch (erreur) { + rendreEchecCreation(erreur); + } + }); + + 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 }); +} + +// 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) { @@ -60,7 +135,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/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/extension/presentation.js b/extension/presentation.js new file mode 100644 index 0000000..b357d53 --- /dev/null +++ b/extension/presentation.js @@ -0,0 +1,82 @@ +// 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_JOUR_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + dateStyle: "full", +}); +const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + 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_JOUR_FLOTTANT = 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) { + 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 : +// 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/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); - }); -}); 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..409ed95 --- /dev/null +++ b/presentation.test.ts @@ -0,0 +1,215 @@ +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_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_DEBUT), + fin: new Date(...JOURNEE_ENTIERE_FIN), + 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"); + }); + + // 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. + 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_DEBUT.join(", ")}), + fin: new Date(${JOURNEE_ENTIERE_FIN.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" }); + }); +});