diff --git a/extension/liste.css b/extension/liste.css
index 8eaf117..9b44f3a 100644
--- a/extension/liste.css
+++ b/extension/liste.css
@@ -53,12 +53,41 @@ h1 {
color: #666;
}
-.evenement-creer {
+.evenement-actions {
flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.35rem;
+}
+
+.evenement-creer,
+.evenement-secondaire {
font: inherit;
cursor: pointer;
}
+.evenement-secondaire {
+ border: none;
+ background: none;
+ padding: 0;
+ color: #666;
+ font-size: 0.85rem;
+ text-decoration: underline;
+}
+
+.evenement-statut {
+ color: #666;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+/* Ignoré : discret mais TOUJOURS visible et actionnable — un ignoré invisible
+ est un ignoré qu'on ne peut plus dé-ignorer. */
+.evenement-ignore .evenement-infos {
+ opacity: 0.55;
+}
+
.evenement-publication {
flex-shrink: 0;
display: flex;
diff --git a/extension/liste.js b/extension/liste.js
index ece5d44..9f284b1 100644
--- a/extension/liste.js
+++ b/extension/liste.js
@@ -1,9 +1,17 @@
import { recupererFluxCalendrier } from "./nextcloud.js";
import { parserEvenements } from "./evenements.js";
-import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js";
+import {
+ formaterDate,
+ formaterLieu,
+ formaterStatut,
+ repousserIgnores,
+ trierParDebut,
+} from "./presentation.js";
import { deposerEvenement } from "./transfert.js";
import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js";
import { indexerCartes, trouverPublication } from "./publication.js";
+import { ecrireStatut, extraireReponses } from "./caldav.js";
+import { jourParis, lireStatut, poserIgnore, poserSoumis, retirerStatut } from "./statut-mairie.js";
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
@@ -18,8 +26,28 @@ const MESSAGES_ERREUR = {
"Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.",
serveur:
"Nextcloud a renvoyé une erreur inattendue. Réessaie dans un moment.",
+ "lecture-seule":
+ "Le calendrier est partagé en lecture seule : demande le droit d'écriture pour enregistrer les statuts.",
+ conflit:
+ "Quelqu'un a modifié cet événement pendant ce temps. Recharge la liste (F5), puis réessaie.",
+ csrf:
+ "Ta session Nextcloud a expiré. Recharge Nextcloud dans un onglet, puis réessaie.",
+ introuvable:
+ "Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).",
+ ambigu:
+ "Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).",
+ "vevent-introuvable":
+ "Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).",
+ "reecriture-risquee":
+ "Par prudence, rien n'a été écrit : cet événement a une forme inhabituelle. Ajoute la catégorie à la main dans Nextcloud.",
+ "ics-illisible":
+ "Par prudence, rien n'a été écrit : cet événement a une forme inhabituelle. Ajoute la catégorie à la main dans Nextcloud.",
};
+// Type volontairement absent de la table : il déclenche le message de repli
+// quand l'écriture échoue pour une raison qu'on n'a pas prévue.
+const ERREUR_IMPREVUE = "imprevu";
+
const etat = document.querySelector("#etat");
const liste = document.querySelector("#liste");
@@ -71,29 +99,156 @@ function rendreEvenement(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 () => {
+ item.append(infos, rendreActions(evenement));
+ refleterStatut(evenement, item);
+ return item;
+}
+
+// Zone d'actions : UNIQUE point de suppression quand l'événement se révèle
+// publié (l'agenda mairie fait foi et écrase tout).
+function rendreActions(evenement) {
+ const actions = document.createElement("div");
+ actions.className = "evenement-actions";
+
+ const statut = lireStatut(evenement.categories);
+ if (statut.type !== "aucun") {
+ const mention = document.createElement("span");
+ mention.className = "evenement-statut";
+ mention.textContent = formaterStatut(statut);
+ actions.append(mention);
+ }
+
+ // Un événement déjà soumis n'a plus de bouton « Créer » : c'est le doublon
+ // d'annonce qu'on combat. Il revient si l'envoi est annulé.
+ if (statut.type !== "soumis") {
+ actions.append(
+ bouton("evenement-creer", "Créer sur le site mairie", (ligne) =>
+ creerSurSiteMairie(evenement, ligne),
+ ),
+ );
+ }
+
+ actions.append(gesteDeStatut(statut, evenement));
+ return actions;
+}
+
+function gesteDeStatut(statut, evenement) {
+ if (statut.type === "aucun") {
+ return bouton("evenement-secondaire", "Ignorer", (ligne) =>
+ appliquerStatut(evenement, ligne, poserIgnore),
+ );
+ }
+
+ const libelle = statut.type === "ignoré" ? "Ne plus ignorer" : "Annuler l'envoi";
+ return bouton("evenement-secondaire", libelle, (ligne) =>
+ appliquerStatut(evenement, ligne, retirerStatut),
+ );
+}
+
+// `action` reçoit la ligne (le
) : le geste ne capture pas le DOM, il le
+// retrouve au clic — la ligne peut avoir été re-rendue entre-temps.
+function bouton(classe, libelle, action) {
+ const element = document.createElement("button");
+ element.type = "button";
+ element.className = classe;
+ element.textContent = libelle;
+ element.addEventListener("click", async () => {
+ const ligne = element.closest(".evenement");
try {
- await creerSurSiteMairie(evenement);
+ await pendantEnregistrement(ligne, element, () => action(ligne));
} catch (erreur) {
rendreEchecCreation(erreur);
}
});
-
- item.append(infos, creer);
- return item;
+ return element;
}
-async function creerSurSiteMairie(evenement) {
+// Double clic impossible : tous les boutons de la ligne sont neutralisés le
+// temps de l'aller-retour. Si la ligne a été re-rendue entre-temps, ces boutons
+// ne sont plus dans le DOM et les réactiver est sans effet.
+async function pendantEnregistrement(ligne, declencheur, action) {
+ const boutons = [...ligne.querySelectorAll("button")];
+ const libelle = declencheur.textContent;
+ boutons.forEach((b) => (b.disabled = true));
+ declencheur.textContent = "Enregistrement…";
+ try {
+ await action();
+ } finally {
+ boutons.forEach((b) => (b.disabled = false));
+ declencheur.textContent = libelle;
+ }
+}
+
+// Écriture partagée : JAMAIS d'affichage optimiste. Tant que Nextcloud n'a pas
+// confirmé, le statut affiché reste celui qu'on sait vrai — mentir sur un état
+// que d'autres bénévoles lisent est pire que ne rien afficher.
+async function appliquerStatut(evenement, ligne, transformer) {
+ const res = await ecrireStatut({
+ // Wrapper obligatoire (cf. charger) : un `fetch` nu lève « Illegal invocation ».
+ fetchImpl: (u, o) => fetch(u, o),
+ extraireReponsesImpl: extraireReponses,
+ uid: evenement.uid,
+ transformer,
+ });
+
+ if (res.ok) {
+ // Les catégories viennent de l'aller-retour serveur, pas de la liste
+ // (potentiellement périmée).
+ evenement.categories = res.categories;
+ rafraichirLigne(evenement, ligne);
+ // Un échec précédent laisserait sinon « rien n'a été enregistré » à l'écran
+ // alors que le tag vient d'être écrit : le mensonge en sens inverse.
+ etat.replaceChildren();
+ } else {
+ rendreEchecStatut(res.erreur);
+ }
+ return res;
+}
+
+// Re-rendu de la SEULE ligne concernée : recharger la liste effacerait les
+// badges « publié » déjà posés par l'annotation asynchrone.
+function rafraichirLigne(evenement, ligne) {
+ ligne.querySelector(".evenement-actions")?.replaceWith(rendreActions(evenement));
+ refleterStatut(evenement, ligne);
+}
+
+function refleterStatut(evenement, ligne) {
+ const ignore = lireStatut(evenement.categories).type === "ignoré";
+ ligne.classList.toggle("evenement-ignore", ignore);
+}
+
+// Le tag « soumis » est un confort d'équipe ; remplir le formulaire est la
+// mission : un échec d'écriture n'empêche pas l'ouverture du formulaire.
+async function creerSurSiteMairie(evenement, ligne) {
+ try {
+ await appliquerStatut(evenement, ligne, (categories) =>
+ poserSoumis(categories, jourParis(new Date())),
+ );
+ } catch (erreur) {
+ // Filet de sécurité : même un imprévu du chemin d'écriture ne doit pas
+ // retenir le formulaire. Le détail part en console, l'utilisateur lit une
+ // phrase, et la mission continue.
+ console.error("Écho du Huit : écriture du statut « soumis » interrompue.", erreur);
+ rendreEchecStatut(ERREUR_IMPREVUE);
+ }
+
// 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 });
}
+// Échec d'un geste de statut : message honnête, statut inchangé.
+function rendreEchecStatut(typeErreur) {
+ etat.replaceChildren();
+ const message = document.createElement("p");
+ message.className = "etat-erreur";
+ message.textContent =
+ MESSAGES_ERREUR[typeErreur] ??
+ "Le statut n'a pas pu être enregistré dans Nextcloud. Réessaie dans un moment.";
+ etat.append(message);
+}
+
// 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
@@ -139,7 +294,7 @@ async function charger() {
// « Illegal invocation » dans le navigateur.
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
if (res.ok) {
- const evenements = trierParDebut(parserEvenements(res.flux, new Date()));
+ const evenements = repousserIgnores(trierParDebut(parserEvenements(res.flux, new Date())));
rendreEvenements(evenements);
// Enrichissement additif : la liste est déjà rendue, l'agenda ne la
// bloque jamais. Un échec de scraping laisse la liste intacte, sans badge.
@@ -178,7 +333,11 @@ async function annoterPublications(evenements) {
// un événement déjà publié n'a pas à être re-soumis). textContent uniquement,
// le lien vient d'une source externe.
function annoterItem(item, lien) {
- item.querySelector(".evenement-creer")?.remove();
+ item.querySelector(".evenement-actions")?.remove();
+ // La mairie fait foi : « publié » écrase TOUT l'affichage du statut, grisé
+ // compris. Sans cette ligne, un événement à la fois ignoré et publié resterait
+ // grisé sans plus aucun bouton — un ignoré qu'on ne peut plus dé-ignorer.
+ item.classList.remove("evenement-ignore");
const publication = document.createElement("div");
publication.className = "evenement-publication";
diff --git a/extension/presentation.js b/extension/presentation.js
index 0f107f8..06dfa70 100644
--- a/extension/presentation.js
+++ b/extension/presentation.js
@@ -5,6 +5,7 @@
// config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md
// 2026-07-17). L'import ne sert qu'à son effet de bord.
import "./config.js";
+import { lireStatut } from "./statut-mairie.js";
const LOCALE = "fr-FR";
const FUSEAU = "Europe/Paris";
@@ -85,3 +86,22 @@ function formaterHorodate({ debut, fin }) {
export function formaterLieu(evenement) {
return evenement.lieu.trim() || LIEU_PAR_DEFAUT;
}
+
+// Statut mairie en clair. Le jour du tag est une date NUE (« AAAA-MM-JJ »),
+// jamais un instant : on la découpe, on ne la convertit pas — un Date la ferait
+// glisser d'un jour selon le fuseau de la machine.
+export function formaterStatut(statut) {
+ if (statut.type === "ignoré") return "Ignoré";
+ if (statut.type !== "soumis") return "";
+
+ if (!statut.jour) return "Soumis";
+ const [, mois, jour] = statut.jour.split("-");
+ return `Soumis le ${jour}/${mois}`;
+}
+
+// Les ignorés restent VISIBLES (on doit pouvoir les dé-ignorer) mais cessent
+// d'encombrer : ils passent en fin de liste, chaque groupe gardant son ordre.
+export function repousserIgnores(evenements) {
+ const ignore = (evenement) => lireStatut(evenement.categories).type === "ignoré";
+ return [...evenements.filter((e) => !ignore(e)), ...evenements.filter(ignore)];
+}
diff --git a/presentation.test.ts b/presentation.test.ts
index 409ed95..920e3ae 100644
--- a/presentation.test.ts
+++ b/presentation.test.ts
@@ -4,6 +4,8 @@ import {
LIEU_PAR_DEFAUT,
formaterDate,
formaterLieu,
+ formaterStatut,
+ repousserIgnores,
trierParDebut,
} from "./extension/presentation.js";
@@ -213,3 +215,44 @@ describe("formaterLieu", () => {
expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT);
});
});
+
+describe("formaterStatut", () => {
+ test("soumis avec date → « Soumis le JJ/MM »", () => {
+ expect(formaterStatut({ type: "soumis", jour: "2026-08-13" })).toBe("Soumis le 13/08");
+ });
+
+ test("soumis sans date exploitable → « Soumis »", () => {
+ expect(formaterStatut({ type: "soumis", jour: null })).toBe("Soumis");
+ });
+
+ test("ignoré → « Ignoré » ; aucun → rien à afficher", () => {
+ expect(formaterStatut({ type: "ignoré" })).toBe("Ignoré");
+ expect(formaterStatut({ type: "aucun" })).toBe("");
+ });
+});
+
+describe("repousserIgnores", () => {
+ test("les ignorés passent en fin, l'ordre relatif est conservé", () => {
+ const evenements = [
+ evenement({ uid: "a" }),
+ evenement({ uid: "ignore-1", categories: ["mairie:ignoré"] }),
+ evenement({ uid: "b", categories: ["Concert"] }),
+ evenement({ uid: "ignore-2", categories: ["Concert", "mairie:ignoré"] }),
+ evenement({ uid: "c", categories: ["mairie:soumis:2026-08-13"] }),
+ ];
+
+ expect(repousserIgnores(evenements).map((e) => e.uid)).toEqual([
+ "a",
+ "b",
+ "c",
+ "ignore-1",
+ "ignore-2",
+ ]);
+ });
+
+ test("liste sans ignoré → inchangée, sans mutation de l'entrée", () => {
+ const evenements = [evenement({ uid: "a" }), evenement({ uid: "b" })];
+ expect(repousserIgnores(evenements).map((e) => e.uid)).toEqual(["a", "b"]);
+ expect(evenements.map((e) => e.uid)).toEqual(["a", "b"]);
+ });
+});