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:
+30
-1
@@ -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;
|
||||
|
||||
+172
-13
@@ -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 <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 {
|
||||
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";
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user