Files
echo-du-huit/docs/DECISIONS.md
T

280 lines
16 KiB
Markdown

# Décisions d'architecture
Choix actés et leur pourquoi. But : éviter aux futurs agents de relitiger ce qui
est déjà tranché. Format léger (date — décision — pourquoi).
## 2026-06-29 — Extension Firefox plutôt que CLI
Première piste : un CLI (Bun) qui fetch l'ICS et auto-soumet le formulaire.
Abandonnée au profit d'une **extension Firefox**.
**Pourquoi :**
- La revue manuelle avant envoi est le vrai besoin (descriptions à enrichir,
formulaire modéré) → pré-remplir + envoyer soi-même bat l'auto-submit.
- L'extension réutilise la **session du navigateur** pour Nextcloud → aucun mot
de passe à stocker.
- Pas de gestion des tokens CSRF : le formulaire les porte lui-même.
## 2026-06-29 — v1 = page autonome de l'extension
Plutôt que d'injecter des boutons dans le calendrier Nextcloud (SPA Vue, DOM
fragile, casse à chaque mise à jour), la v1 est une **page propre** à
l'extension. L'injection dans le calendrier reste un *bonus* ultérieur.
## 2026-06-29 — Statut dans le tag CATEGORIES (CalDAV)
L'intention (*ignoré* / *soumis*) est écrite dans l'événement Nextcloud, pas
dans un stockage local.
**Pourquoi :** partagé entre bénévoles, durable, visible dans le calendrier.
Un `state.json` local serait privé à une machine et invisible des autres.
Le statut *publié* n'est pas un tag : il est **dérivé** de l'agenda public de la
mairie (match titre + date), ce qui donne aussi le lien direct.
## 2026-06-29 — Image ajoutée manuellement
Un content script ne peut pas remplir un `<input type=file>` (sécurité
navigateur). L'extension facilite le geste (affiche l'image à glisser) mais ne
l'automatise pas. L'affiche vient souvent de la newsletter Brevo, hors
calendrier.
## 2026-06-30 — Parseur iCalendar (T1) : contrat `Event` figé + ical.js vendoré
Le flux brut est transformé en `Event[]` par une fonction pure
`parserEvenements(flux, aujourdhui)` (`extension/evenements.js`), via **ical.js
v2.2.1 vendoré** (approche 3 du brainstorm : pas de build, dépendance en `.js`).
**Contrat `Event` figé** (ne pas rouvrir sans accord) :
`{ uid, titre, description, lieu, debut: Date, fin: Date, categories: string[],
journeeEntiere: boolean }`. Champs texte coercés en `""` si absents (l'aval
suppose des chaînes). Le défaut « café » du lieu est appliqué **en aval**, pas
ici.
**Décisions tranchées :**
- **`uid` seul, pas de `href`/`ETag`.** Le transport actuel (`?export`) renvoie
un ICS concaténé sans `href`/`ETag` par événement. L'`UID` est la clé stable ;
T2 résoudra l'adressage CalDAV par UID au moment du PUT. Le contrat n'est pas
alourdi.
- **`journeeEntiere` dès T1** (drapeau porté, comportement « heure exigée »
différé à T3).
- **Cas limites traités « au plus simple », différés à T3** : pas d'expansion
RRULE (récurrents pris au DTSTART maître + filtre futur → un récurrent au
maître **passé** disparaît, **trou assumé et documenté**), exceptions
d'occurrence (`RECURRENCE-ID`) ignorées, journées entières au minuit local.
- **Tri & message « 0 événement futur » hors parseur** : le parseur renvoie les
événements **dans l'ordre du flux**, non triés (responsabilité de la liste).
## 2026-06-29 — Pré-remplissage par content script, pas par URL
Gravity Forms n'accepte `?input_X=` que si chaque champ est configuré
« population dynamique » côté mairie (improbable). On remplit donc le **DOM par
id** (ids confirmés dans `RECHERCHE.md`).
## 2026-07-14 — [T1] Liste : couture page → content script
La liste dépose l'événement choisi dans `storage.local` **puis** ouvre le
formulaire mairie (`extension/transfert.js`). Ce que la tâche sœur
(« formater les données pour le formulaire mairie ») doit lire :
**Clé unique `evenement-en-attente`, PAS `evenement:<uid>`** (révise la couture
« clé = uid » annoncée par la tâche sœur, qui avait un trou). Le content script
s'exécute sur le site de la mairie : il n'a **aucun canal** pour apprendre un
`uid`, le fragment d'URL ayant été écarté (pas de pollution d'une URL tierce).
Une clé connue d'avance le rend lisible sans transporter quoi que ce soit.
- Pas de résidus : un seul slot, écrasé au clic suivant (le dernier clic gagne —
limite assumée, le geste réel est séquentiel et le formulaire est modéré).
- **On n'efface pas à la lecture** : le formulaire survit à un rechargement.
- Le dépôt est `await` **avant** `tabs.create` : sinon le content script peut
lire avant l'écriture.
**`debut`/`fin` sérialisés en ISO 8601** (le payload n'est pas un `Event`). On ne
parie pas sur le structured clone : le contrat reste vrai quel que soit le
moteur, le content script réhydrate avec `new Date(iso)`.
⚠️ **Si `journeeEntiere` est vrai, l'instant ISO n'est PAS significatif.** Une
`DTSTART;VALUE=DATE` est une date *flottante* (RFC 5545) : elle n'a pas de
fuseau. ical.js la matérialise à **minuit local**, donc l'ISO produit dépend de
la machine (`2026-07-10` devient `...T15:00:00Z` depuis Tokyo). Le consommateur
doit **tester `journeeEntiere` d'abord** et n'en lire que les composantes
**locales** (`getFullYear`/`getMonth`/`getDate`) — jamais l'heure, jamais une
conversion de fuseau. C'est pour la même raison que la liste formate les
journées entières **sans** `timeZone` (`presentation.js`) : forcer `Europe/Paris`
faisait glisser la date au jour précédent depuis Tokyo ou Auckland.
⚠️ **`fin` est un DTEND EXCLUSIF (RFC 5545) — ne pas l'afficher/soumettre tel quel.**
Le payload porte la valeur brute du parseur. Pour une **journée entière**, un
événement du 18 porte `fin` = le **19**, et un festival du 18 au 20 porte `fin` =
le **21** : le dernier jour réel est **`fin` moins un jour** (composantes locales,
jamais « -24h »). Concerne directement la tâche sœur, qui remplit `#input_6_32`
(Date fin) **depuis DTEND** : sans ce recul d'un jour, elle soumettra une date
**fausse** à la mairie. La liste applique déjà la règle (`presentation.js`,
`veilleDe`). Autre cas mesuré : **`DTEND` absent → le parseur pose `fin === debut`**
(il n'y a alors pas de plage à afficher, ni de fin à soumettre).
**`TZ=Europe/Paris` sur `bun test` est PORTEUR — ne pas le retirer.** Le runner
de Bun force **`TZ=UTC`** quand `TZ` est absente (vérifié : `bun -e` lit le
fuseau système, le runner non). Sans le pin, les tests ne tournent donc pas dans
le fuseau des bénévoles. Corollaire pour l'écriture des tests : un fixture de
date flottante (journée entière, `DTSTART` sans `Z` ni `TZID`) se construit en
**composantes locales** (`new Date(2026, 6, 10)`), jamais en instant absolu
(`new Date("2026-07-10T00:00:00+02:00")`) — ce dernier n'est juste que sur une
machine à +02:00 et ment partout ailleurs.
**Défaut café = affichage seulement ; `lieu` stocké brut** (`""` si absent). Les
deux avals du parseur ont chacun le leur : la liste affiche l'adresse pour ne pas
montrer une ligne vide (cas majoritaire), le content script décide s'il pose
**aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload
détruirait le signal « pas de lieu » dont il a besoin.
## 2026-07-17 — [T1] Pré-remplissage : content scripts classiques + `globalThis`
Le formulaire mairie est pré-rempli par deux content scripts (`config.js`,
`formulaire-valeurs.js`, `formulaire-mairie.js`), **classiques — pas des modules
ESM** — qui communiquent par des namespaces `globalThis` (`EchoConfig`,
`EchoFormulaire`).
**Pourquoi pas ESM :** un content script n'est pas un module (`import` y lève une
`SyntaxError`), et le `import()` dynamique y est cassé côté Firefox (bugs ouverts
1803950 / 1536094). Restait à convertir la couture `transfert.js`, figée le
2026-07-14 : non.
⚠️ **`config.js` et `formulaire-valeurs.js` n'ont NI `import` NI `export`, et
c'est délibéré — ne pas « corriger ».** Un fichier sans les deux est valide **à la
fois** comme script classique (content script, background) et comme module ESM
(`import "./config.js"` depuis `presentation.js` et depuis `bun test`). C'est ce
qui permet à `presentation.js` de partager l'adresse du café **sans build**. Le
jour où quelqu'un y ajoute un `export`, les content scripts cassent
silencieusement.
**Corollaire : l'ordre des tableaux `js` du manifest EST la dépendance**
(`config` → `valeurs` → `mairie`), testé dans `extension.test.ts`.
**`CLE_EVENEMENT_EN_ATTENTE` est dupliquée** dans `formulaire-valeurs.js`
(l'original vit dans `transfert.js`, en ESM, inatteignable depuis un content
script classique). Un **test de dérive** garantit l'égalité des deux.
**Config (a) : défauts committés + `config.local.json` optionnel.** Les défauts
(thème, tarifs, café) sont dans `config.js` ; seuls organisateur et email sont
personnels et vivent dans `config.local.json` (gitignoré). Le fichier n'est
**pas requis** : un clone frais doit marcher, il laisserait sinon l'extension
cassée par défaut. Absent ou invalide → défauts nus, manque **signalé** dans le
bandeau, jamais d'exception.
**C'est le background qui lit `config.local.json`**, puis le passe par le
storage. Un content script ne pourrait le `fetch(runtime.getURL(...))` que si le
fichier était en `web_accessible_resources` — ce qui **exposerait l'email à la
page de la mairie**. Le fichier étant optionnel, il ne peut pas non plus être
listé dans un tableau `js` (Firefox refuse d'installer si un fichier déclaré
manque) : `fetch` + repli est la seule voie.
**On ne remplit que ce qu'on sait ; le reste est dit, jamais inventé.** Pas
d'heure « 00:00 » pour une journée entière, pas de `lat|lng` deviné pour un lieu
hors café, pas d'organisateur par défaut. Ce qui manque part dans le bandeau
(`aCompleter`). En particulier, **`input_6_35` (accessibilité) est laissé
intact** : `RECHERCHE.md` le dit « fixe », mais la machine ne sait pas si le lieu
est accessible — cocher au hasard sur un formulaire **modéré** serait exactement
le « champ rempli faux » qu'on veut éviter.
**Séparation calcul / DOM** : `formulaire-valeurs.js` est pur et testé (les dates
sont le vrai risque, invisible sous `TZ=Europe/Paris`) ; `formulaire-mairie.js`
est fin, impur et **non testé unitairement** — aucun harnais DOM dans le repo, et
happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui
tranche.
## 2026-07-17 — [T2] Publication mairie : dérivée, paginée, seam pur/impur
Le statut *publié* est **dérivé** de l'agenda public de la mairie (pas de tag),
en retrouvant l'événement Nextcloud par **date locale de Cugnaux + titre
normalisé**. Il donne aussi le **lien direct** (lu dans la carte).
**Corrections factuelles vérifiées en phase plan :**
- **L'agenda est PAGINÉ** : `.../agenda/page/N/?f=1&…`. Un seul fetch « période »
ne suffit pas → on boucle jusqu'à une page vide (plafond de sécurité anti-boucle).
- **Le slug porte des suffixes WordPress** (« Atelier dessin » →
`/agenda/atelier-dessin-2/`) : on matche sur **date + titre**, jamais sur le
slug. Le lien est **lu** dans la carte, jamais reconstruit.
- `<time datetime="YYYY-MM-DD">` est une **date nue** (pas d'heure) → comparer à
la date locale de Cugnaux du `debut`, jamais à une conversion de fuseau.
Journée entière = date flottante → composantes locales (comme `presentation.js`).
**Seam extraction impure / matching pur** (même logique que
`formulaire-valeurs.js` vs `formulaire-mairie.js`) : `DOMParser` est **absent de
Bun**, donc `extraireCartes(html)` (`agenda-mairie.js`) est impur et **non testé
unitairement** (test humain sur l'agenda réel) ; le **matching** (`publication.js`)
et la boucle de pagination (via impls injectées) sont purs et **testés à fond** —
c'est le vrai risque produit.
**Biais PRÉCISION v1** : match STRICT (date identique + titre normalisé
identique). Titre proche mais non strictement identique le même jour → on
**n'affirme pas** « publié » (faux négatif bénin ; le formulaire modéré rattrape
un doublon éventuel). Dans le doute — agenda injoignable, HTTP ≠ 200, DOMParser
qui lève, structure HTML changée — la liste s'affiche **sans badge** (dégradation
gracieuse, jamais d'exception, aucun faux positif). Deux cartes de même clé
(même jour + même titre normalisé, rare) : la **dernière indexée** l'emporte.
**Périmètre v1 = badge « publié » + lien uniquement.** L'annotation est
**additive** : la liste est rendue immédiatement, l'agenda ne la bloque jamais.
Fetch de l'agenda **sans** `credentials` (public). Aucune modification de
`manifest.json` (`host_permissions` couvre déjà `ville-cugnaux.fr`).
## 2026-08-13 — [T2] Écriture du statut : REPORT + If-Match, garde-fou, annulable
**Résolution paresseuse de la ressource, jamais d'URL devinée.** Au moment
d'écrire, un `REPORT` (`calendar-query` filtrée sur l'`UID`) rend `href` + `ETag`
+ `calendar-data`, puis on `PUT` avec `If-Match`. Mesuré : le nom du fichier
`.ics` **ne correspond pas** à l'UID (cf. RECHERCHE.md §1), l'approche
« `<uid>.ics` » aurait produit une panne partielle. `If-Match` transforme
l'écriture concurrente en **412** (« recharge la liste ») au lieu d'un écrasement
silencieux. 0 réponse → `introuvable`, plusieurs `href` → `ambigu` : on n'écrit
jamais dans une ressource choisie au hasard.
**Garde-fou de non-régression.** L'aller-retour `ical.js` pourrait abîmer une
propriété exotique. Avant tout `PUT`, on re-parse l'ICS **avant** et **après**,
on retire `CATEGORIES` des deux arbres jCal et on compare : différence ailleurs →
**aucun PUT**, message honnête. La comparaison est **structurelle**, donc le
repliement de ligne et l'espacement peuvent changer librement — une **valeur**,
non. On écrit dans l'agenda réel d'une association : un tag non posé se repose,
un événement abîmé se répare à la main.
**Ni `SEQUENCE` ni `DTSTAMP`/`LAST-MODIFIED` ne sont bumpés** : le garde-fou
l'interdit, et poser une catégorie n'est pas une modification de planification.
La synchro des autres clients passe par l'ETag/ctag.
**Le CSRF est un confort, pas une condition** : `/csrftoken` rend 200, mais un
jeton **invalide** passe quand même sur DAV (mesuré). On envoie l'en-tête
`requesttoken` quand on l'a, et un échec de récupération n'est **pas fatal** :
le serveur tranche.
**Le tag `soumis` est posé au clic « Créer » et reste annulable.** Rien ne prouve
qu'un formulaire modéré a été envoyé ; le clic est le meilleur signal disponible,
donc le statut porte toujours « Annuler l'envoi » et le bouton « Créer »
disparaît (c'est le doublon d'annonce qu'on combat). **Un échec d'écriture
n'empêche pas d'ouvrir le formulaire** : le tag est un confort d'équipe, remplir
le formulaire est la mission.
**Jamais d'affichage optimiste** : le statut affiché ne change qu'après
confirmation du serveur, et ce sont les catégories **renvoyées par
l'aller-retour** qui sont conservées. Mentir sur un état que d'autres bénévoles
lisent est pire que ne rien afficher. Seule la **ligne concernée** est re-rendue :
recharger la liste effacerait les badges « publié » déjà posés.
**Les ignorés sont grisés et repoussés en fin de liste, jamais masqués** : un
ignoré invisible est un ignoré qu'on ne peut plus dé-ignorer.
**Orthographe du tag : `mairie:ignoré`, avec l'accent** — ces tags sont lus par
des humains dans Nextcloud et l'ICS est de l'UTF-8. À re-constater au premier
aller-retour réel : si Nextcloud renormalise, basculer en ASCII et le noter.
**Limite assumée v1** : une ressource = un tag, donc un récurrent porte le même
statut pour toutes ses occurrences (cohérent avec le report des récurrences en T3).
**Limite honnête du garde-fou** : il compare `parse(ics)` à
`parse(toString(parse(ics)))`. Il attrape donc ce que la **sérialisation** perd,
pas ce que le **parsing** perdrait des deux côtés à la fois. Vérifié à
l'écriture : `ical.js` round-trippe sans dommage les formes exotiques d'un vrai
calendrier (paramètre quoté à virgule, propriété inconnue, `ATTACH` binaire,
`EXDATE` multiple, accent sur la frontière de repliement) — ces cas sont figés en
test. Le vrai arbitre reste le test humain « rien d'autre n'a bougé ».