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:
Pierre Martin
2026-07-14 18:23:38 +02:00
co-authored by Claude Opus 4.8
parent d1a1c194bb
commit 3d86330894
9 changed files with 405 additions and 9 deletions
+31
View File
@@ -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
View File
@@ -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);
}
+1
View File
@@ -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/*"
+42
View File
@@ -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;
}
+35
View File
@@ -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),
});
}