feat: let the list ignore, submit and undo events from Nextcloud

Each row now shows its shared status and carries the matching gesture. Nothing
is displayed before Nextcloud confirms it: on failure the status stays put and
the reason is spelled out. Ignored events are greyed and pushed to the end,
never hidden.
This commit is contained in:
Pierre Martin
2026-08-13 16:50:16 +02:00
parent 058229dce3
commit 69c10abbd7
4 changed files with 265 additions and 14 deletions
+30 -1
View File
@@ -53,12 +53,41 @@ h1 {
color: #666; color: #666;
} }
.evenement-creer { .evenement-actions {
flex-shrink: 0; flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
}
.evenement-creer,
.evenement-secondaire {
font: inherit; font: inherit;
cursor: pointer; 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 { .evenement-publication {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
+171 -12
View File
@@ -1,9 +1,17 @@
import { recupererFluxCalendrier } from "./nextcloud.js"; import { recupererFluxCalendrier } from "./nextcloud.js";
import { parserEvenements } from "./evenements.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 { deposerEvenement } from "./transfert.js";
import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js"; import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js";
import { indexerCartes, trouverPublication } from "./publication.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/"; 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.", "Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.",
serveur: serveur:
"Nextcloud a renvoyé une erreur inattendue. Réessaie dans un moment.", "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 etat = document.querySelector("#etat");
const liste = document.querySelector("#liste"); const liste = document.querySelector("#liste");
@@ -71,29 +99,156 @@ function rendreEvenement(evenement) {
infos.append(titre, date, lieu); infos.append(titre, date, lieu);
const creer = document.createElement("button"); item.append(infos, rendreActions(evenement));
creer.type = "button"; refleterStatut(evenement, item);
creer.className = "evenement-creer"; return item;
creer.textContent = "Créer sur le site mairie"; }
creer.addEventListener("click", async () => {
// 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 <li>) : 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 { try {
await creerSurSiteMairie(evenement); await pendantEnregistrement(ligne, element, () => action(ligne));
} catch (erreur) { } catch (erreur) {
rendreEchecCreation(erreur); rendreEchecCreation(erreur);
} }
}); });
return element;
}
item.append(infos, creer); // Double clic impossible : tous les boutons de la ligne sont neutralisés le
return item; // 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);
} }
async function creerSurSiteMairie(evenement) {
// Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant // Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant
// l'écriture. // l'écriture.
await deposerEvenement(browser.storage.local, evenement); await deposerEvenement(browser.storage.local, evenement);
await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE }); 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 // 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 // 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 // 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. // « Illegal invocation » dans le navigateur.
const res = await recupererFluxCalendrier((u, o) => fetch(u, o)); const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
if (res.ok) { if (res.ok) {
const evenements = trierParDebut(parserEvenements(res.flux, new Date())); const evenements = repousserIgnores(trierParDebut(parserEvenements(res.flux, new Date())));
rendreEvenements(evenements); rendreEvenements(evenements);
// Enrichissement additif : la liste est déjà rendue, l'agenda ne la // Enrichissement additif : la liste est déjà rendue, l'agenda ne la
// bloque jamais. Un échec de scraping laisse la liste intacte, sans badge. // 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, // un événement déjà publié n'a pas à être re-soumis). textContent uniquement,
// le lien vient d'une source externe. // le lien vient d'une source externe.
function annoterItem(item, lien) { 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"); const publication = document.createElement("div");
publication.className = "evenement-publication"; publication.className = "evenement-publication";
+20
View File
@@ -5,6 +5,7 @@
// config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md // config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md
// 2026-07-17). L'import ne sert qu'à son effet de bord. // 2026-07-17). L'import ne sert qu'à son effet de bord.
import "./config.js"; import "./config.js";
import { lireStatut } from "./statut-mairie.js";
const LOCALE = "fr-FR"; const LOCALE = "fr-FR";
const FUSEAU = "Europe/Paris"; const FUSEAU = "Europe/Paris";
@@ -85,3 +86,22 @@ function formaterHorodate({ debut, fin }) {
export function formaterLieu(evenement) { export function formaterLieu(evenement) {
return evenement.lieu.trim() || LIEU_PAR_DEFAUT; 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)];
}
+43
View File
@@ -4,6 +4,8 @@ import {
LIEU_PAR_DEFAUT, LIEU_PAR_DEFAUT,
formaterDate, formaterDate,
formaterLieu, formaterLieu,
formaterStatut,
repousserIgnores,
trierParDebut, trierParDebut,
} from "./extension/presentation.js"; } from "./extension/presentation.js";
@@ -213,3 +215,44 @@ describe("formaterLieu", () => {
expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT); 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"]);
});
});