feat: list upcoming events with a create button
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d1a1c194bb
commit
3d86330894
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+62
-8
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> = {};
|
||||
return {
|
||||
ecrit,
|
||||
set: async (objet: Record<string, unknown>) => 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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user