From 3d86330894b6b103fad1d0aee58af3306a314a9b Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 14 Jul 2026 18:09:47 +0200 Subject: [PATCH] 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" }); + }); +});