Files
gestion_projets/docs/decisions.md
Bertrand Benjamin 3c7cff3ac6 Décider d'un cycle de vie pour les projets, et d'un horizon
Une petite mairie a plus de projets imaginables que de temps pour les
mener, et la crainte première est d'en oublier. La section 27 pose les
quatre états — envisagé, engagé, terminé, écarté —, l'horizon en mois
des envisagés, et surtout ce qu'on écarte : la matrice d'Eisenhower, un
backlog séparé, une vue de rétrospective.

Elle garde aussi la trace de ce que le premier essai a corrigé, dont
l'immobilité de la barre d'horizon, abandonnée à l'usage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:56:14 +02:00

1055 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Décisions de conception
Ce document garde la trace des arbitrages et surtout de leurs raisons, pour éviter de refaire les
mêmes débats dans six mois.
## 1. Pourquoi un outil sur-mesure
Les suites complètes (Redmine, Taiga, OpenProject) exigent un serveur applicatif et une base de
données à administrer — hors de proportion avec le besoin. Les mini-outils « un seul fichier HTML »
sont des Kanban sans dates.
[Markwhen](https://markwhen.com) est le concurrent sérieux : timeline en couloirs engendrée depuis du
texte markdown-ish, open source, self-hostable, avec extension VS Code et vue Gantt. Il couvre une
bonne partie du besoin.
Deux choses manquent, et ce sont elles qui justifient ce projet :
- la **manipulation directe** des barres (glisser, redimensionner) ;
- le **suivi de dérive** face à un planning de référence figé.
Si ces deux besoins disparaissaient, il faudrait sérieusement envisager d'abandonner cet outil au
profit de Markwhen.
## 2. Pas de dépendances entre phases
Une première version du cahier des charges prévoyait des dépendances fin→début avec propagation en
cascade. Abandonné.
Le coût est très supérieur à ce qu'il paraît : graphe orienté, détection de cycles, et surtout une
sémantique de propagation à trancher — la poussée est-elle une contrainte dure ou souple, la marge
existante est-elle conservée, avancer un prédécesseur tire-t-il ses successeurs en arrière, que
faire d'une date imposée de l'extérieur. Chacune de ces questions a plusieurs réponses défendables,
et se tromper produit un outil qui déplace des barres dans le dos de l'utilisateur.
En regard, le bénéfice réel en planification macro est faible. Le besoin concret — « ce projet glisse
d'un mois » — se traite en décalant les phases concernées à la main, ce qui prend quelques secondes
sur un projet de cinq ou six phases.
**Conséquence heureuse** : sans flèches à tracer entre les barres, le SVG perd sa raison d'être (voir
décision 4).
## 3. Micro-serveur Python plutôt que File System Access API
La première approche envisagée était d'écrire directement dans le fichier depuis le navigateur, via
`showOpenFilePicker` / `createWritable`, pour se passer de tout back-end.
> **Rectification (1ᵉʳ août 2026).** Le paragraphe suivant affirmait que l'API « ne fonctionne pas
> depuis `file://` ». C'est faux, et la vérification sur Chromium 150 le montre : `file://` **est**
> un contexte sécurisé (`isSecureContext === true`), `showSaveFilePicker` y ouvre bien son
> dialogue, et `localStorage` comme IndexedDB y persistent d'une session à l'autre — un handle
> peut donc y être mémorisé. Ce qui bloque réellement en `file://`, c'est autre chose : les
> **modules ES** et `fetch` y sont interdits par CORS (origine `null`), donc c'est la décision 10
> qui ferme cette voie, pas la sécurité de l'API. La conclusion ci-dessous — lancer un serveur —
> reste la bonne, mais pour ce motif-là. Voir décision 22.
Cette API exige un **contexte sécurisé**, et l'on avait cru qu'elle ne fonctionnait pas depuis
`file://`. Il fallait donc de toute façon lancer un serveur statique. Le « zéro back-end » était
perdu d'avance, tout en payant trois prix : limitation aux navigateurs Chromium, re-autorisation du
sélecteur de fichier à chaque session, et maintien d'un chemin de repli export/import pour Firefox
et Safari, doublé d'un miroir `localStorage`.
Puisqu'une commande doit être lancée dans tous les cas, autant qu'elle serve à quelque chose. Un
serveur de la bibliothèque standard, `GET` et `PUT` sur `/api/data`, tient en une centaine de lignes
— soit **moins** de code que l'API navigateur plus son repli plus son miroir. Et il fonctionne
partout, sans sélecteur à réautoriser.
Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée, ce que la File System
Access API ne permettait pas simplement. Elle l'était alors à chaque écriture ; son rythme a depuis
changé, voir décision 23.
## 4. DOM et CSS plutôt que SVG
Le SVG s'imposait tant qu'il fallait tracer des flèches courbes entre des barres. Les dépendances
supprimées, il ne reste que des rectangles, du texte et des losanges.
Des `div` positionnées en absolu font tout cela plus simplement : troncature du texte par
`text-overflow`, curseurs de redimensionnement, styles de survol, transitions, tout est natif en CSS.
En SVG il faudrait mesurer le texte à la main et réimplémenter des comportements que le navigateur
offre gratuitement. Les losanges de jalon se font par une rotation CSS de 45°.
## 5. Identifiants parlants plutôt qu'UUID
Le fichier de données est censé rester ouvrable dans un éditeur de texte et versionné dans git. Des
UUID rendent les deux pénibles : impossible de savoir quelle phase on lit, et un diff illisible.
`site-web` et `cadrage` coûtent la même chose à manipuler et gardent le fichier compréhensible. La
contrepartie — gérer les collisions à la création — est traitée par un suffixe numérique.
Un identifiant ne change **jamais** quand le nom est modifié : il identifie, il ne décrit pas.
Renommer une phase ne doit pas invalider les références qui pointent vers elle.
## 6. Statut plutôt que pourcentage d'avancement
Un pourcentage d'avancement sur une phase macro de plusieurs semaines est presque toujours un chiffre
inventé, et il donne une fausse impression de précision. Quatre statuts — à venir, en cours, terminé,
bloqué — se saisissent en un clic et disent l'essentiel. `blocked` en particulier porte une
information qu'un pourcentage ne peut pas exprimer.
## 7. Jours calendaires
Gérer les jours ouvrés impose de faire passer chaque calcul de date par un helper, et soulève
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des
phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation.
## 8. La barre cumulative est en lecture seule
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On
aurait pu la rendre glissable pour décaler tout le projet en bloc.
Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt
un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail
d'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit
sur les phases.
**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce
qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était
en train d'éditer.
**Et elle est rendue à l'identique dans les deux cas.** Une première version l'épaississait en mode
plié, au motif qu'elle y était seule sur sa ligne. C'était une erreur : plier un couloir ne change
rien à ce que cette barre représente, et la voir changer d'aspect au pli laissait croire à deux
objets différents. Elle garde donc partout la même hauteur, plus mince qu'une barre de phase —
puisqu'elle voisine avec elles dès que le projet est déplié, et qu'elle ne se glisse pas. Le fantôme
de référence se cale dessous, d'où sa variante `--cumulatif`.
## 9. Accroche à la semaine
Au zoom trimestre, un jour représente quelques pixels : viser une date précise à la souris devient
un exercice de patience. L'accroche au lundi rend le geste franc et correspond à la granularité
réelle d'une planification macro.
Les dates exactes restent saisissables au clavier dans le panneau de détail, ce qui couvre les cas
où une phase doit démarrer un jeudi précis.
## 10. Zéro build
Modules ES natifs, chargés directement par le navigateur. Pas de `package.json`, pas de bundler, pas
de `node_modules`. Le fichier qu'on lit dans l'éditeur est exactement celui qu'exécute le navigateur,
ce qui rend le débogage direct et l'outil reprenable dans plusieurs années sans exhumer une chaîne de
compilation obsolète.
Node n'est utilisé que pour lancer les tests, via son lanceur intégré (`node --test`), sans aucune
dépendance.
**Portée après la décision 22.** Le serveur se compile désormais, mais le front, lui, ne se
compile toujours pas : aucun bundler, aucune transformation, aucun `node_modules`. Les fichiers de
`js/` sont embarqués tels quels dans le binaire et servis tels quels au navigateur — c'est bien la
ligne qu'on lit dans l'éditeur qui s'exécute, et `--dev` les sert directement depuis le disque.
Ce qui était visé ici est donc intact ; ce qui change, c'est seulement la façon dont le serveur est
distribué.
## 11. Un en-tête à trois bandes, la semaine pour grain le plus fin
L'en-tête affichait une seule rangée de graduations dont le contenu changeait selon le
zoom : des quantièmes en vue semaine, des noms de mois en vue trimestre. Il fallait
deviner l'unité affichée, et l'année n'apparaissait qu'au zoom le plus large.
Trois bandes empilées — **année**, **mois**, **numéro de semaine ISO** — lèvent
l'ambiguïté : les trois repères sont toujours là, quel que soit le zoom. Chaque cellule
est un bloc couvrant exactement son intervalle, ce qui centre le libellé sur la période
qu'il désigne au lieu de le laisser flotter après un trait.
Le jour a disparu : sur des phases qui se comptent en semaines, il n'apportait rien et
encombrait l'affichage. Le zoom ne change donc plus l'unité, seulement la largeur des
mêmes semaines.
Les numéros suivent l'**ISO 8601** : la semaine appartient à l'année où tombe son jeudi.
C'est pourquoi `semaineISO()` renvoie l'année en même temps que le numéro — afficher
« S53 » sous un bandeau « 2027 » serait faux, alors que le 1er janvier 2027 est bien en
semaine 53 de 2026.
## 12. Défilement natif sur les deux axes plutôt que synchronisé en JavaScript
La colonne des libellés était un conteneur séparé, recalé sur le défilement vertical de
la frise par un `translateY` appliqué à chaque événement `scroll`. Deux défauts : un
décalage visible d'une image sur les défilements rapides, et deux systèmes de coordonnées
à tenir cohérents.
Tout tient désormais dans un unique conteneur défilant, découpé en grille 2×2 : l'en-tête
est `sticky top`, la colonne des libellés `sticky left`, et le coin les deux à la fois. Le
navigateur gère seul le défilement des deux axes. Le code de synchronisation a disparu.
## 13. Menu contextuel plutôt que `prompt()` et `confirm()`
Les actions d'un projet passaient par un `prompt()` listant des numéros d'action à saisir
au clavier, et les suppressions par un `confirm()`. C'était rapide à écrire et pénible à
utiliser : rien n'indiquait qu'il fallait cliquer sur le nom du projet, et ces boîtes
natives bloquent le fil d'exécution.
Un bouton `⋯` explicite ouvre maintenant un vrai menu, navigable au clavier, où les
actions indisponibles sont grisées plutôt qu'absentes — « Retirer la référence » reste
visible quand aucune référence n'est figée, ce qui apprend que la fonction existe.
Les dialogues s'appuient sur l'élément `<dialog>` natif, qui fournit le voile, le piège à
focus, la fermeture par Échap et la restauration du focus sans code supplémentaire. Sur
une action destructrice, le focus initial se pose sur « Annuler » : un appui réflexe sur
Entrée ne doit rien détruire.
Une exception assumée : ajouter une phase ne demande pas son nom dans un dialogue. La
phase est créée immédiatement à la suite de la précédente, et le panneau de détail
s'ouvre avec le nom présélectionné — l'utilisateur tape simplement par-dessus. Un
dialogue de plus n'aurait rien apporté.
## 14. Une seule échelle, pas de zoom
Trois niveaux de zoom — semaine, mois, trimestre — étaient proposés. Retirés.
Depuis que l'en-tête empile l'année, le mois et le numéro de semaine (décision 11), les
trois repères utiles sont lisibles en permanence. Le zoom ne résolvait plus qu'un problème
qu'il avait lui-même créé : savoir quelle unité on regardait. Il restait un réglage de
plus à comprendre, un état de plus à gérer, et un chemin de code de plus à tester.
L'échelle unique est de **6 pixels par jour**, soit 42 pixels par semaine : assez pour que
« S32 » tienne dans sa cellule, assez serré pour qu'une année tienne dans une fenêtre de
portable.
## 15. Une frise qui s'élargit au défilement
La fenêtre affichée se déduisait des bornes des phases existantes, avec une marge fixe.
Conséquence gênante : impossible d'aller regarder un futur vide pour y planifier. La frise
s'arrêtait là où s'arrêtaient les données.
La fenêtre est désormais un **état de vue** distinct des données. Elle démarre autour des
phases existantes, puis s'élargit de six mois dès que le défilement passe à moins de
400 pixels d'un bord. On peut ainsi partir vers 2031, y poser une phase, et revenir.
Deux subtilités :
- **Élargir vers la gauche déplace l'origine de l'échelle**, donc tout le contenu se
décale vers la droite. On compense `scrollLeft` de la même quantité, sans quoi la vue
sauterait en arrière à chaque élargissement. Un drapeau empêche l'ajustement de
relancer le gestionnaire de défilement.
- **La fenêtre ne rétrécit jamais**, même si les phases se resserrent : le terrain déjà
exploré doit rester atteignable.
L'élargissement s'arrête à **dix ans de part et d'autre des données**. Ce n'est donc pas
infini au sens strict — c'est un garde-fou : rien n'est virtualisé, et un défilement
prolongé ferait autrement enfler le DOM sans limite. À l'échelle d'une planification
macro, dix ans dépassent largement l'horizon utile.
Corollaire : le recentrage est instantané, jamais animé. Sur une frise qui peut atteindre
plusieurs dizaines de milliers de pixels, une animation serait lente, et le gestionnaire
de défilement l'interromprait en élargissant la fenêtre au passage près d'un bord.
Attention en modifiant `centrerSur` : `scrollLeft` porte sur la grille entière, dont la
première colonne est celle des libellés. Comme elle est `sticky`, elle masque en
permanence les premiers pixels de la vue, et le centre utile des couloirs est décalé
d'une demi-largeur de colonne par rapport au centre de la frise.
## 16. Tags libres et filtre conjonctif
Passé une dizaine de projets, la frise devient un mur : on veut n'y voir que les projets
clients, ou que ceux d'un pôle. D'où des **tags** sur le projet, et un filtre.
**Étiquettes libres, pas de référentiel.** Aucune liste de tags autorisés n'est tenue
quelque part : les tags disponibles sont ceux que portent les projets, et un tag disparaît
de la barre de filtres dès qu'il n'orne plus rien. Un référentiel séparé aurait exigé une
interface pour le gérer, et se serait désynchronisé. La contrepartie — la dérive
orthographique, « client », « Clients », « client final » — est traitée par deux gardes :
la comparaison ignore la casse, et le dialogue d'un projet propose en un clic les tags déjà
employés ailleurs.
**Le filtre exige *tous* les tags cochés**, pas au moins un. Cocher un tag de plus resserre
donc toujours la sélection : on part du tout et on élague, sans jamais voir la frise se
repeupler en cochant. La disjonction se comporte à l'inverse, et il faut alors relire la
liste des tags actifs pour savoir dans quel sens la prochaine case va jouer.
**Le filtre n'est pas enregistré.** C'est un état de vue, comme la fenêtre temporelle
(décision 15) : un filtre qui survivrait au rechargement rouvrirait le planning amputé sans
qu'on se souvienne pourquoi. Le masquage d'un projet (`hidden`), lui, est bien enregistré —
et les deux ne font pas la même chose :
| | Filtre par tags | Masquage (`hidden`) |
|---|---|---|
| Portée | La vue entière, libellé compris | Les barres seules, le libellé reste grisé |
| Durée de vie | La session | Enregistré dans le fichier |
| Question posée | « Je ne regarde que les projets clients » | « Ce projet existe, mais il encombre ma frise » |
Le masquage n'a plus de bouton dédié dans la colonne : voir décision 20.
**La couleur d'un tag est calculée, pas choisie** : une teinte HSL dérivée du nom par
hachage. Rien à stocker, rien à saisir, et le même tag garde sa couleur partout. Des
collisions de teinte sont possibles et sans gravité — le nom est toujours écrit à côté.
**Un tag trop long est tronqué, pas refusé.** Contrairement à une date invalide, qui bloque
le chargement en nommant le fautif, la longueur d'un tag est cosmétique : refuser d'ouvrir
un planning entier pour un libellé bavard serait disproportionné. La limite est donc
appliquée en normalisant, ce qui la rend vraie par construction — aussi bien pour un fichier
édité à la main que pour une saisie dans le dialogue.
Créer un projet alors qu'un filtre est actif **préremplit ses tags** avec ceux du filtre :
sans cela, le nouveau venu naîtrait invisible, hors de la vue depuis laquelle on vient de le
demander.
## 17. Le panneau de détail n'est pas modal
Le panneau s'ouvrait derrière un voile couvrant toute la page. L'intention était
raisonnable — concentrer l'attention sur la phase en cours d'édition —, mais elle
allait contre ce que fait un outil de frise : on y compare, on passe d'une phase à
la suivante, on ajuste en regardant le voisinage.
Trois conséquences, dont deux invisibles à la lecture du code :
- passer d'une phase à l'autre imposait fermer, cliquer, rouvrir, alors que c'est
le geste le plus fréquent de l'outil ;
- le raccourci `←` `→` était **inatteignable**. Le panneau plaçait le focus dans
le champ « Nom », et `app.js` ignore les flèches quand la frappe part dans un
`input` — pour en sortir il aurait fallu cliquer ailleurs, ce que le voile
interdisait précisément ;
- le rafraîchissement du panneau depuis la frise, prévu pour qu'un glisser se
reflète dans les champs, ne pouvait jamais se produire à la souris.
Le voile a donc disparu. Le panneau reste posé sur le bord droit, la frise
continue de vivre derrière lui, et cliquer une autre barre y bascule la
sélection. Le focus se pose sur le panneau lui-même — assez pour que le clavier
le suive, pas assez pour capturer les flèches. Le champ « Nom » n'est
présélectionné que pour une phase fraîchement créée, dont le nom provisoire est
justement là pour être écrasé.
**Cliquer à côté ferme quand même.** C'était le seul service que rendait le
voile, et il ne valait pas la frise inerte qu'il coûtait. Un écouteur sur
`document` le rend sans calque : il ferme les panneaux au `pointerdown`, sauf
sur deux familles de cibles.
- Les **surfaces protégées** — un panneau, le menu, un dialogue, la poignée de
colonne — où l'on est manifestement au travail.
- Les **cibles ouvrantes** : une barre, une poignée de barre, un libellé de
phase, tout élément portant `data-action`. Leur propre gestionnaire ouvre déjà
le bon panneau, et les faire passer par la fermeture générale serait pire
qu'inutile : celle-ci redessine la frise, ce qui détacherait du DOM l'élément
visé avant que son gestionnaire — ou le glisser qui commence peut-être — n'ait
fini. C'est aussi pourquoi `fermer()` sort tout de suite quand le panneau est
déjà fermé : sans cette garde, chaque clic dans le vide relancerait un rendu
complet.
Échap et la croix restent les deux autres façons de fermer.
## 18. La colonne des libellés est redimensionnable
La colonne portait, sur 250 pixels fixes, le triangle de pli, le nom, les tags,
l'étiquette de dérive, l'œil et le menu. Sur le jeu d'exemple, il ne restait que
**56 pixels au nom** : « Refonte du site web » s'affichait « Refon… », pendant que
« client » se réduisait à « c. ». Le pire des deux mondes — des pastilles qui
occupaient la place d'un tag sans en livrer le nom.
Trois corrections, dans l'ordre de leur rendement :
- **Les commandes passent en surimpression**, révélées au survol de la ligne.
Ce sont des actions occasionnelles ; elles coûtaient soixante pixels en
permanence à une information lue en continu. Elles sont posées en `absolute`,
et non retirées du flux au repos, pour que rien ne se déplace à l'approche du
curseur. Elles restent visibles sans survol sur un projet masqué, seul chemin
pour le rétablir.
- **Les tags cèdent en bloc plutôt que de rétrécir.** Deux pastilles au plus,
puis un compteur `+N` dont l'infobulle donne les manquants. Un tag présent dans
le filtre courant passe devant : c'est lui qui explique la présence du projet à
l'écran, il ne doit pas tomber dans le compteur.
- **Le nom cède en dernier**, et pas en deçà de sept caractères.
Restait qu'aucune largeur ne convient à la fois à « Site web » et à « Refonte du
portail fournisseurs — lot 2 ». D'où une poignée dans le coin de la grille —
seul point de la colonne qui reste visible quel que soit le défilement —, avec
double-clic pour revenir au défaut et flèches au clavier.
**Cette largeur est conservée, dans `localStorage`.** C'est la première entorse à
la règle des décisions 15 et 16, où les états de vue meurent avec la session, et
elle est délibérée : la fenêtre temporelle et le filtre décrivent *ce qu'on
regarde*, une largeur de colonne décrit *l'écran sur lequel on regarde*. La
placer dans le planning imposerait à un portable la largeur choisie sur un
27 pouces ; ne pas la garder du tout obligerait à réajuster la poignée à chaque
ouverture, ce qui la rendrait inutile. `localStorage` est exactement le bon
rangement : local à la machine, hors des données.
## 19. Le nom d'une phase reste accroché au bord visible
Le libellé d'une barre était écrit à son bord gauche. Une phase de plusieurs mois
dont le début sortait de l'écran occupait donc toute la largeur de la vue sans
qu'on puisse lire son nom, parti sous la colonne des libellés.
Le nom vit désormais dans son propre élément, en `position: sticky`, calé sur la
largeur de la colonne — c'est-à-dire sur le premier pixel réellement visible de
la frise. Trois détails le rendent possible, et aucun n'est cosmétique :
- un `overflow: hidden` sur la barre en aurait fait le conteneur défilant de
référence, et le décalage n'aurait plus eu lieu. Le rognage du texte trop long
est donc porté par le nom lui-même, pas par la barre ;
- le décalage est plafonné par la boîte du parent, ce qui garantit que le nom ne
sort jamais de sa barre — d'où `inline-block`, qui laisse au texte la marge de
manœuvre qu'un bloc pleine largeur n'aurait pas ;
- quand le nom occupe presque toute sa barre, la marge s'épuise et il finit par
s'enfoncer sous la colonne. On lit alors sa fin plutôt que rien, ce qui reste
un progrès, et la colonne des libellés donne de toute façon le nom entier.
## 20. Ce que porte la ligne d'un projet
Trois commandes s'y étaient accumulées sans qu'on se demande si chacune méritait
sa place. Le tri s'est fait sur une seule question : cette action est-elle assez
fréquente pour valoir un bouton permanent ?
**L'œil disparaît.** Il faisait doublon avec l'entrée « Masquer de la frise » du
menu, qui reste. Le masquage est une décision durable sur un projet — on le pose
une fois et on l'oublie — pas un geste qu'on refait dix fois par séance. Depuis
que les tags existent (décision 16), c'est d'ailleurs le filtre qui répond au
besoin courant de dégager la vue. La fonction demeure, elle ne coûte simplement
plus de pixels à tout le monde en permanence. Le format de données est inchangé :
`hidden` reste un champ du projet.
**Le `+` apparaît.** Ajouter une phase est l'action la plus fréquente de l'outil,
et elle était enterrée dans un menu.
**Le nom du projet devient cliquable.** Il portait déjà `cursor: pointer` sans
qu'aucune action n'y réponde — un curseur qui promet et ne tient pas. Il ouvre
maintenant le dialogue des paramètres, ce que la main annonçait.
**Il n'y a plus de commande « Ajouter un jalon ».** Un jalon n'est pas un autre
objet : c'est une phase dont la fin rejoint le début, soit une case à cocher du
panneau au même titre que le statut. Deux entrées de menu obligeaient à trancher
avant d'avoir rien saisi, alors que la bascule reste possible ensuite dans les
deux sens. Une seule commande crée une phase ; on coche « Jalon » si c'en est un.
## 21. Un projet s'édite comme une phase
Les paramètres d'un projet — nom, couleur, tags — passaient par un `<dialog>`
modal à valider, quand ceux d'une phase s'éditaient dans un panneau latéral au
fil de la saisie. Deux formes pour la même opération à un niveau de granularité
près, avec deux modèles mentaux à tenir : ici on tape et ça s'applique, là on
tape et il faut confirmer.
Les projets ont rejoint le panneau (`js/projet.js`, jumeau de `js/detail.js`).
Même en-tête, même édition en direct, même bouton de suppression au bas du
formulaire. Les deux panneaux occupent le même bord de l'écran : ouvrir l'un
ferme l'autre.
**« Nouveau projet » ne demande donc plus rien.** Le projet est créé aussitôt
avec un nom provisoire, et le panneau s'ouvre avec ce nom présélectionné — on
tape par-dessus. C'est exactement le parti pris déjà retenu pour l'ajout d'une
phase (décision 13), qui cesse ainsi d'être une exception : plus aucune création
ne passe par un formulaire à valider.
Conséquences en cascade, toutes bienvenues :
- la couleur s'applique au clic sur sa pastille, donc la frise change sous les
yeux — le seul moyen de juger si la teinte se distingue de celle des couloirs
voisins, ce qu'un aperçu dans un dialogue ne montrait pas ;
- il n'y a plus de bouton « Annuler », donc plus le cas où fermer la fenêtre
perdait la saisie ;
- `creerDialogueProjet` disparaît de `menu.js`, qui ne garde que le menu
contextuel et la confirmation.
Le `<dialog>` natif reste pour cette dernière : sur une action irréversible,
interrompre franchement *est* le but, et le voile, le piège à focus et la
fermeture par Échap y sont gratuits.
Une contrepartie assumée : un projet créé par mégarde existe dans le fichier
même si l'on ferme le panneau sans rien saisir. Il porte alors le nom
« Nouveau projet », se voit sur la frise, et se supprime d'un clic depuis son
propre panneau — ce qui reste moins coûteux que d'imposer un dialogue à tout le
monde à chaque création.
## 22. Un binaire Go plutôt qu'un script Python
`serve.py` supposait Python installé et une commande tapée dans un terminal, depuis le dossier du
dépôt. C'est une friction quotidienne pour un outil qu'on veut ouvrir d'un geste, et un obstacle
net sous Windows, où Python est absent par défaut.
L'objectif visé : **poser l'outil et son planning dans un dossier synchronisé, et les retrouver
sur n'importe quelle machine**. Trois voies menaient là.
**Un `index.html` autonome en `file://`.** Séduisante — zéro exécutable, longévité maximale — mais
elle impose de renoncer aux modules ES, bloqués par CORS depuis `file://` (voir la rectification en
décision 3), donc de concaténer les sources en un fichier unique. Et surtout elle enferme dans
Chromium : Firefox et Safari n'implémentent pas la File System Access API.
**Tauri.** Accès direct au disque, sans dialogue ni permission, et le code du navigateur reste
intact. Mais un webview système à installer sous Linux (WebKitGTK), un rendu à vérifier sur trois
moteurs différents, une chaîne Rust et une intégration continue multi-OS pour produire les
binaires. C'est contradictoire avec la décision 10 : une chaîne Tauri de 2026 ne se recompilera pas
aussi sûrement dans dix ans qu'un fichier HTML s'ouvrira.
**Un binaire Go qui contient le serveur et l'interface**, retenu. Il conserve exactement
l'architecture éprouvée — mêmes routes, même fichier JSON, mêmes sauvegardes horodatées, front
inchangé — en supprimant la seule vraie friction, l'installation. `CGO_ENABLED=0` produit un
exécutable statique de 6 Mo qui ne dépend de rien, pas même de la glibc, et les cinq plateformes se
compilent depuis une seule machine sans intégration continue.
Face à `file://`, il garde **tous** les navigateurs et les modules ES. Face à Tauri, il n'exige
aucun webview et divise l'outillage par dix. Ce qu'il concède : l'interface s'ouvre dans un onglet
plutôt que dans une fenêtre d'application, et il faut un binaire par plateforme.
Deux choix de détail méritent d'être notés.
Le port n'est plus fixe : lancé par un double-clic, l'utilisateur n'a aucun moyen de passer
`--port` si 8000 est occupé. Le serveur essaie donc les dix premiers ports libres à partir de 8000.
Un `--port` explicite, lui, n'est jamais contourné — c'est un choix de l'utilisateur.
Le fichier de données est cherché **à côté de l'exécutable**, pas dans le répertoire courant, qui
est imprévisible lors d'un double-clic. Le mode `--dev` fait exception : il sert l'interface depuis
le disque et lit `data/projets.json`, pour qu'un rafraîchissement suffise à voir ses modifications
sans recompiler.
`serve.py` a été supprimé une fois le binaire validé en usage réel. Il n'y avait rien à migrer : le
format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de
le relire se présentait.
## 23. Une sauvegarde par séance, pas une par modification
Le serveur copiait le planning dans `backups/` avant **chaque** écriture. Comme l'interface
enregistre après une seconde d'inactivité, une séance d'édition un peu soutenue produisait des
dizaines de fichiers quasi identiques.
Le défaut n'est pas l'espace occupé — cinquante copies d'un planning macro pèsent 250 Ko — mais ce
que la rotation en fait : les cinquante emplacements se remplissaient en une après-midi, et
chassaient précisément les états anciens qu'on cherche à retrouver. Un filet de sécurité qui ne
remonte pas plus loin que la dernière demi-heure protège de la faute de frappe, pas de la bêtise
qu'on découvre le lendemain.
La copie est donc prise **à l'ouverture**, et une seule fois. C'est le bon moment : l'état d'avant
la séance est exactement celui vers lequel on veut revenir, et il est figé avant qu'on y touche.
Deux garde-fous complètent la règle :
- si le serveur reste allumé longtemps — un serveur local qu'on ne ferme jamais est un cas
réel —, une copie est reprise au plus une fois par jour, faute de quoi une session de trois
semaines ne laisserait qu'un seul point de reprise ;
- si le planning est identique à la dernière sauvegarde, rien n'est écrit. Rouvrir l'outil sans
avoir rien modifié n'a pas à consommer un emplacement.
À raison d'une copie par séance, les cinquante conservées couvrent des mois. Pour un historique
plus long ou plus fin, la vraie réponse n'est pas d'en garder davantage mais de versionner le
planning dans git, ce que son format texte indenté rend parfaitement lisible (voir décision 5).
Ce changement a rendu concurrentes deux étapes qui ne l'étaient pas — la copie et le remplacement
dépendent maintenant d'un état partagé, la date de la dernière sauvegarde. Un verrou les sérialise,
ce qui protège au passage du cas où deux onglets enregistrent en même temps.
## 24. Une vue par mois, en lecture seule, à côté de la frise
La frise répond très bien à « comment ce projet-là s'étale-t-il », et très mal à « qu'est-ce qui
arrive le mois prochain ». La seconde question traverse les projets, alors que la frise les range
précisément en couloirs séparés : il faut balayer une colonne de dates à travers tous les couloirs,
en gardant en tête ce qu'on a vu plus haut. Avec cinq projets et une échelle de six pixels par
jour, c'est déjà pénible ; c'est la raison pour laquelle on ressortait un tableur à côté.
D'où une seconde vue, atteinte par deux boutons dans la barre d'outils. Trois arbitrages la
définissent.
**Une phase est rattachée au mois de son début, et à lui seul.** Une phase de trois mois n'apparaît
donc qu'une fois. La répéter dans chaque mois traversé aurait donné un tableau de charge —
« qu'est-ce qui tourne en octobre » —, qui est une question légitime mais une autre vue : le
doublon coûte la lecture en diagonale qui fait tout l'intérêt de celle-ci, et brouille le compte
affiché en tête de mois. La fin est écrite sur la ligne (« 31 → 13 nov. »), ce qui suffit à voir
qu'une phase déborde et jusqu'où.
**Les mois sans rien ne sont pas représentés.** Un planning à horizon de deux ans a des trous ;
intercaler « novembre 2026 — rien » ferait défiler du vide, alors que l'absence se lit déjà dans le
saut d'un titre au suivant.
**La liste ne s'édite pas.** Aucune poignée, aucun glisser, et un clic sur une ligne n'ouvre pas le
panneau de détail. Ce n'est pas une lacune à combler plus tard : déplacer une phase demande de voir
ce qu'elle chevauche et ce qu'elle repousse, c'est-à-dire le voisinage que la frise montre et que
la liste, ordonnée par date et non par projet, a justement dissous. Une date qu'on modifie sans
voir son contexte est une date qu'on modifie à l'aveugle. La vue sert donc à faire le point, la
frise à arbitrer. Seules les pastilles de tags restent cliquables — filtrer ne change pas les
données, c'est une façon de regarder.
Les **notes** d'une phase, elles, se consultent ici : un triangle en tête de ligne les déplie en
dessous. Lire n'est pas éditer, et une note est précisément ce qu'on va chercher quand on fait le
point — « pourquoi cette phase est-elle bloquée ». Elles restent repliées par défaut, pour deux
raisons. La densité d'abord : trois lignes de notes sous chaque intitulé étalent un mois sur deux
écrans et ruinent la lecture en diagonale. Et parce qu'elles ne sont pas régulières — une phase sur
trois en porte —, les afficher d'office produirait des lignes de hauteurs inégales, ce qui casse
l'alignement des colonnes sur lequel repose tout le balayage du regard.
**Toute la ligne déplie, pas seulement le triangle.** Une première version faisait de l'intitulé
lui-même le bouton, avec un chevron à sa suite : la cible était trop petite pour un geste aussi
banal, et rien ne justifiait cette parcimonie dans une vue où le clic n'a aucun autre effet. Le
triangle reste, mais comme indicateur plutôt que comme cible — c'est lui qui dit ce qui va se
passer, et il reprend le triangle qui plie un projet sur la frise, au même endroit et avec la même
rotation : ce qui s'apprend d'un côté vaut de l'autre. Le curseur et le fond au survol signalent
que la ligne réagit. Les lignes sans notes, elles, ne réagissent pas : promettre un geste qui ne
fait rien est pire que ne rien promettre.
Le triangle n'en reste pas moins un vrai `button` : la ligne, elle, n'est pas focusable, et c'est
donc lui le seul chemin clavier vers les notes. L'état déplié vit dans le DOM et nulle part
ailleurs — il ne survit pas à un changement de filtre, qui reconstruit la liste. Le remonter dans
l'état de l'application pour qu'il y survive serait payer cher une continuité que personne ne
réclame : quand la liste change, on la relit.
Le filtre par tags, lui, vaut pour les deux vues sans rien changer à sa mécanique : c'est le même
état, la même barre, au même endroit. Un filtre posé sur la frise se retrouve en passant à la
liste, ce qui rend la bascule sûre — on ne se demande jamais si l'une montre plus que l'autre. Les
deux tamis de la frise s'appliquent d'ailleurs à l'identique : l'œil (`hidden`) écarte un projet de
la liste comme il écarte ses barres.
La vue choisie n'est pas enregistrée, contrairement à la largeur de la colonne des libellés
(décision 18). Cette largeur est un réglage d'écran, la vue est un geste de consultation : on passe
par la liste pour faire le point, on revient à la frise pour travailler. L'outil s'ouvre donc
toujours sur la frise, celle où l'on modifie. Le défilement horizontal de la frise, lui, est
mémorisé le temps de l'aller-retour : masquer un élément lui fait perdre son `scrollLeft`, et
revenir des années avant ce qu'on regardait serait une punition pour avoir consulté la liste.
Deux conséquences dans le code, toutes deux de bon aloi : les noms de mois et `formaterDateLongue`
sont remontés de `timeline.js` vers `model.js`, et les pastilles de tags dans `tags.js`. Deux vues
sœurs les emploient, aucune n'a à importer l'autre pour écrire « septembre ».
## 25. Les notes sont en markdown, et le projet en a
Une phase avait des notes : un `textarea` de quatre lignes au bas du panneau, du texte brut. Un
projet n'en avait aucune. Les deux manques se tiennent.
**Le projet en avait besoin.** La frise dit quand les choses arrivent ; elle ne dit pas qui pilote,
sur quel budget, ni pourquoi le prestataire est engagé jusqu'en janvier. Ce contexte-là finissait
dans les notes de la première phase venue, où il n'a rien à faire — il survit à la phase, il ne
survit pas à sa suppression. Le champ est donc monté d'un cran, et le panneau d'un projet reçoit le
même bloc que celui d'une phase : renommer un projet et renommer une phase sont la même opération à
un niveau près (décision 21), y écrire une note aussi.
**Quatre lignes fixes étaient un plafond arbitraire.** Les autres champs du panneau ont une taille
dictée par leur contenu — une date en fait dix caractères, un statut en fait quatre. Une note n'a
aucune taille propre : elle fait ce qu'on a à dire. Elle prend donc toute la hauteur que les champs
au-dessus lui laissent, avec un plancher sous lequel le corps du panneau défile plutôt que de
l'écraser.
### Pourquoi un analyseur maison
Le markdown demandait une dépendance, ou du code. Une dépendance aurait été le premier
`node_modules` de l'outil, contre la décision 10, pour une syntaxe dont on n'emploie ici qu'une
poignée de formes. On a donc écrit `js/markdown.js` : un sous-ensemble, environ trois cents lignes,
sans DOM ni réseau — ce qui le rend testable sous Node comme `model.js`, le rendu proprement dit
vivant dans `notes.js`.
Le choix du sous-ensemble suit ce qu'on écrit dans une note de suivi : titres, emphase, code,
listes, cases à cocher, liens, citations, séparateur. Pas de tableaux ni d'images — ils ne tiennent
pas dans un volet latéral. Pas de HTML brut, jamais : le DOM est bâti nœud par nœud, sans un seul
`innerHTML`. Un planning s'échange, on en ouvre un qu'un autre a écrit, et une note est du texte
libre ; la seule façon sûre d'en afficher est de ne jamais laisser le navigateur l'interpréter comme
du balisage. Les URL passent une liste blanche de schémas — `http`, `https`, `mailto` — et un lien
refusé retombe sur son libellé plutôt que de disparaître.
Deux écarts au standard, assumés. **Un retour à la ligne en est un** : CommonMark recolle les lignes
d'un paragraphe et exige deux espaces en fin de ligne pour un vrai retour, piège invisible dans un
champ où l'on jette une petite liste à la volée. Et **ce qui n'est pas reconnu reste du texte** :
`**gras` sans fermeture s'affiche tel quel, parce qu'une note à moitié écrite est l'état normal
d'une note et que l'aperçu doit suivre la frappe sans à-coups.
### On lit d'abord, on écrit ensuite
Le bloc s'ouvre sur l'**aperçu** quand la note existe, sur la **saisie** quand elle est vide. C'est
l'usage : on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour y consigner
quelque chose. Un crayon bascule d'un état à l'autre, et la barre d'outils ne paraît qu'en saisie —
en aperçu elle ne commanderait rien, et sa place revient au texte.
Six boutons, pas plus : gras, italique, titre, liste, case à cocher, lien. Le markdown entier reste
accessible au clavier puisque c'est du texte ; la barre n'est là que pour épargner la syntaxe des
formes courantes. Une barre exhaustive prendrait deux rangées dans le volet et repousserait la note
d'autant.
Les **cases à cocher se cliquent dans l'aperçu**, et c'est le seul geste de l'aperçu qui modifie les
données. Une note de suivi contient des choses à faire ; les cocher en rouvrant la saisie pour
transformer un `[ ]` en `[x]` serait une corvée absurde. La bascule réécrit **la seule ligne visée
dans le texte d'origine** plutôt que de régénérer la note depuis l'arbre : régénérer normaliserait
au passage les marqueurs, l'indentation et l'emphase de l'utilisateur, qui verrait sa mise en forme
réécrite pour avoir coché une case.
Dans la **vue par mois**, en revanche, les cases sont inertes. La liste est en lecture seule
(décision 24) et une case cliquable y serait la seule chose qu'on puisse modifier — une exception
isolée dans une vue dont toute la promesse est qu'on n'y casse rien. On les voit cochées ou non, ce
qui est justement ce qu'on vient y chercher quand on fait le point.
### `Ctrl+Z` doit continuer de marcher
Une barre d'outils écrit dans le champ à la place de l'utilisateur, et la façon évidente de le faire
— affecter `champ.value`**vide la pile d'annulation** du navigateur. `Ctrl+Z` cesse alors de
remonter au-delà du bouton pressé, et la frappe qui précédait devient irrécupérable. C'est un prix
qu'on ne peut pas faire payer à un champ de saisie : annuler est le réflexe le plus élémentaire
qu'on ait devant du texte, et l'ancien champ de notes, lui, l'honorait sans qu'on ait rien à écrire.
Toutes les écritures passent donc par `document.execCommand('insertText')`, qui laisse le navigateur
enregistrer l'opération comme si elle avait été tapée. L'API est marquée obsolète et reste sans
remplaçant pour cet usage : les `InputEvent` synthétiques qui devaient lui succéder ne modifient
rien, un navigateur ignorant les événements qu'il n'a pas produits. Tenir notre propre historique
reviendrait à réimplémenter `Ctrl+Z`, `Ctrl+Y` et la fusion des frappes voisines — sans commune
mesure avec le service rendu par un champ de notes. Si la commande échoue, on retombe sur
l'affectation directe : on perd l'annulation, jamais la saisie.
Deux gardes vont avec. `execCommand` écrit **là où est le focus**, sans considération pour l'élément
qu'on croit viser : on vérifie donc que le champ l'a bien pris, faute de quoi la commande irait
insérer du markdown dans le champ « Nom ». Et une insertion vide n'en est pas une — c'est une
suppression, que `insertText` ne traite pas partout, d'où le passage par `delete`.
Une seule écriture y échappe : cocher une case depuis l'aperçu, où le champ est masqué et ne peut
pas prendre le focus. Le geste vide donc la pile — sans grande conséquence, puisqu'on n'était pas en
train d'y taper.
### Le volet s'élargit, les infobulles s'aplatissent
Trois cent quarante pixels suffisaient à des champs de date ; ils ne suffisent pas à une liste
imbriquée, où chaque niveau mange une indentation. Le panneau reçoit donc la **même poignée** que la
colonne des libellés (décision 18) : même geste, même double-clic de remise à zéro, même
mémorisation dans `localStorage` — c'est un réglage d'écran, pas une donnée à synchroniser. Une
largeur unique pour les deux panneaux, parce que ce qu'on élargit est le volet, pas l'un ou l'autre
de ses contenus. Elle est plafonnée à la fenêtre sans être écrasée pour autant : la largeur voulue
et la largeur appliquée sont deux variables distinctes, faute de quoi réduire un instant la fenêtre
ferait perdre le réglage.
Les **infobulles** de la frise, elles, ne peuvent pas montrer de rendu : l'attribut `title` du
navigateur ne connaît que le texte. Les notes y sont donc **aplaties** — marques retirées, et
remplacées par un caractère qui porte le même sens là où il y en a un : `•` pour un tiret, `☐` et
`☑` pour une case, `│` pour une citation. Sans cela, survoler une barre afficherait la source
markdown, astérisques comprises, soit une note *moins* lisible qu'avant qu'on ne l'enrichisse. Deux
infobulles taisaient d'ailleurs les notes et les disent maintenant : celle d'un jalon — c'est
pourtant là qu'on consigne une décision — et celle du libellé d'un projet, seul endroit de la frise
où ses notes à lui peuvent se lire, faute de barre qui lui appartienne.
### Le format passe en version 4
Ajouter un champ n'oblige à rien : une version 3 se relit sans encombre. Mais le validateur
reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît pas. Sans
incrémenter, un binaire antérieur ouvrirait un fichier plus riche que lui sans broncher et en
effacerait toutes les notes de projet à la première sauvegarde. Le numéro ne sert qu'à cela : faire
échouer bruyamment ce qui échouerait silencieusement.
## 26. Un curseur clavier, et des raccourcis à la vim
L'outil savait déjà décaler une phase aux flèches, mais pas la **choisir** : `selection` n'était
posée que par `ouvrirPhase`, appelée depuis un clic. Il fallait donc la souris pour commencer, et
tout raccourci ajouté par-dessus aurait hérité de cette dépendance.
### `selection` devient `curseur`, et survit à la fermeture
L'ancienne variable disait deux choses à la fois : « voici la phase que je manipule » et « le
panneau est ouvert dessus ». Les deux se séparent.
Le curseur désigne une **ligne de la frise**, et la ligne d'un projet en est une : c'est de là qu'on
plie, qu'on renomme, qu'on ajoute une phase et qu'on supprime le projet. D'où `{ projet, phase }`
avec `phase` à `null`, plutôt que deux curseurs concurrents dont l'un serait toujours à ignorer.
Il **survit à `Échap`**, alors que `selection` s'effaçait : un curseur qui disparaît à la fermeture
du volet ne peut pas servir de point de départ au déplacement suivant. En contrepartie, la marque
reste visible après un clic dans le vide — c'est le prix d'un curseur, et il se paie une fois.
Comme il peut désigner une ligne qu'un changement vient d'emporter — phase supprimée, projet replié,
filtre resserré —, `dessiner` commence par le **normaliser** : la ligne du projet sert de refuge,
puisqu'elle survit à ces trois cas, et l'effacement n'est que le dernier recours. Placer ce contrôle
dans `dessiner` plutôt qu'à chaque appelant garantit qu'aucun chemin ne l'oublie.
### Le volet suit le curseur
Ce n'est pas un agrément mais une nécessité : les raccourcis agissent sur le curseur, et un panneau
resté sur une autre phase donnerait deux cibles concurrentes à l'écran. On éditerait dans les champs
une phase que `Espace` ou `dd` n'atteindraient pas. Ouvert, le panneau est donc la vue détaillée du
curseur — ce qui fait de `j` et `k` le moyen le plus rapide de relire tout un projet.
Ce parcours passe par un **clic** sur une barre, qui ouvre le volet sans prendre le champ. Ouvrir au
clavier place la frappe dans le nom, où les raccourcis se taisent, et `Échap` referme tout : les deux
usages ne se rejoignent pas, et c'est un compromis assumé plutôt qu'un oubli. Le rendre continu
demanderait de faire d'`Échap` une sortie de champ avant d'être une fermeture — deux appuis là où il
en faut un aujourd'hui, y compris à la souris.
### Deux couches qui ne se recouvrent pas
Les **flèches modifient** la phase du curseur, les **lettres déplacent** et commandent. Aucun
raccourci documenté n'a changé de sens.
La décision 17 tient, mais son partage se déplace. Elle laissait le focus sur le panneau et non dans
un champ, pour que les flèches restent atteignables juste après avoir sélectionné une phase — au
clic, c'est toujours le cas. Ouvrir le volet **au clavier**, en revanche, est une demande explicite
d'écrire : `Entrée` et `e` saisissent donc le nom tous les deux. Ce qui protège les flèches n'est
plus le focus mais `Échap`, qui referme le volet sans déplacer le curseur — « `e`, taper, `Échap`,
flèches » enchaîne sans la souris.
Ce qui ne doit **jamais** prendre le focus, c'est le volet qui suit un déplacement du curseur : la
frappe suivante partirait dans le champ, où les raccourcis se taisent, et on ne saisirait plus jamais
qu'un nom. D'où le paramètre `saisir` de `ouvrirSousCurseur`, vrai sur commande, faux au passage.
`clavier.js` ne connaît rien du planning : il traduit des frappes en noms d'actions, que `app.js`
lui fournit. Les séquences à deux temps se déclarent dans la même table que les autres, et les
préfixes — `g` aller, `z` plis, `d` détruire — s'en déduisent au lieu d'être listés à part. Un
préfixe resté en attente s'oublie au bout de deux secondes : vim ne le fait pas, mais vim n'est pas
posé sur un écran qu'on quitte des yeux, et un `d` abandonné puis retrouvé cinq minutes plus tard
viserait une autre ligne.
Les touches **se taisent** dès que le clavier appartient à quelqu'un d'autre : une saisie en cours,
un dialogue modal, le menu contextuel — qui navigue déjà aux flèches — ou un glisser en cours. Sans
cela, `a` dans un nom de phase créerait une phase au lieu d'écrire un `a`.
`AltGr` est la seule combinaison tolérée avec `Maj`, et il n'y a pas le choix : sur un clavier
français, `{` et `}` ne s'obtiennent qu'avec lui. Windows le présente comme `Ctrl`+`Alt`, si bien
que refuser les deux ensemble rendrait ces deux touches inatteignables sur les binaires Windows.
### Insérer se dit en dates
`model.js` trie les phases par date de début. « Avant » et « après » ne peuvent donc pas s'exprimer
en rangs : `o` et `O` calculent une date et laissent le tri faire le reste. Les quatre points
d'insertion — `A` à la fin du projet, `a` au début, `o` après la phase du curseur, `O` avant — ne
diffèrent que par ce calcul, et passent tous par `ajouterUnePhase`, qui garde son comportement
d'origine quand aucune date ne lui est donnée.
`P` reste à part : un projet naît **hors de tout curseur**, sans date et sans ligne de référence.
L'agréger à la famille `a`/`o` aurait suggéré une position qu'il n'a pas.
### Le curseur se peint dans la colonne, pas seulement sur la barre
La barre portait déjà un contour. Il ne suffit pas : le curseur se pose sur des lignes qui n'ont
aucune barre en face — celle d'un projet, un projet plié, un projet masqué. La colonne des libellés
est le seul endroit qui porte une ligne pour *chaque* position atteignable, et c'est aussi ce qui
permet à `app.js` d'y compter les enfants pour amener le défilement au bon endroit. L'énumération de
`lignesFrise` et celle de `construireLibelles` doivent donc rester d'accord.
Sur la ligne d'un projet, le trait d'accent seul se noyait dans son fond coloré et son triangle de
pli : elle prend aussi une teinte, mélangée à sa couleur propre plutôt qu'au fond de la surface. La
teinte passe par une redéfinition de `--fond-libelle` et non par `background`, parce que le dégradé
qui donne un fond aux commandes du projet s'appuie sur cette même variable — un fond posé en direct
y laissait une couture.
### Le `?` n'est pas facultatif
Des raccourcis d'une lettre ne se devinent pas. Un bouton dans la barre d'outils et la touche `?`
ouvrent la même table, dans un `<dialog>` **modal** — contrairement aux deux panneaux : celui-ci ne
sert qu'à lire, et laisser `j` déplacer le curseur derrière la table qui explique `j` serait absurde.
La table est écrite à la main plutôt que bâtie depuis `SEQUENCES`, qui associe une touche à un nom
d'action : cela ne dit ni ce que l'action fait, ni dans quel ordre la présenter, ni quelles touches
vont par paires. La génération aurait produit une liste exacte et illisible.
### La vue par mois n'a pas de curseur
Elle se lit, elle ne s'édite pas (décision 24) : rien n'y aurait de sens à désigner. `j`, `k`, `gg`
et `G` y font donc ce qu'ils font dans un document — ils déroulent la liste. `gm` et `ga` marchent
dans les deux vues, le reste attend le retour à la frise.
## 27. Le cycle de vie d'un projet, et l'horizon
L'outil est employé pour piloter les chantiers d'une petite mairie : beaucoup plus de projets
imaginables que de temps pour les mener, et une crainte première — **ne rien oublier**. Une idée
qu'on ne peut pas traiter cette année ne doit pas disparaître pour autant.
Or l'outil ne connaissait que les projets déjà planifiés. Tout ce qui n'était pas encore engagé
vivait ailleurs — un carnet, une tête, un compte rendu de réunion — de sorte que l'arbitrage se
rendait **sans voir le planning**, alors que c'est précisément le planning qui dit s'il y a la
place.
### La priorité est une décision d'avant l'engagement
Le point de départ était une matrice d'Eisenhower dans le panneau d'un projet. Deux objections
l'ont déplacée.
L'urgence est **déjà dans les dates**. La resaisir crée un champ qui se périme en silence : un
projet coché « urgent » en juin ne l'est plus en septembre, et rien ne viendra le décocher.
Surtout, une fois un projet planifié, **l'arbitrage est déjà rendu** — ce sont ses dates qui le
portent. Une priorité posée sur un projet engagé ne déciderait plus rien, elle commenterait. Le
lieu de la priorisation est donc en amont, sur ce qui n'est pas encore engagé.
### Quatre états, dont deux se déduisent
Un projet traverse quatre états, et deux d'entre eux se lisent dans les données sans qu'on ait
rien à tenir à jour :
| État | Comment on le sait |
|---|---|
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
| **Engagé** | on lui a donné au moins une phase datée |
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
| **Écarté** | on a décidé de ne pas le faire |
Une règle unique les résume : **les phases commandent l'état, sauf quand on a prononcé quelque
chose.** Livré n'est pas clos, et abandonner est une décision — ces deux-là s'actent. Le reste se
déduit.
**Clore un projet termine toutes ses phases avec lui.** Sans cela la frise se contredirait : une
ligne éteinte au-dessus de segments pâles, et la vue par mois annonçant des tâches à venir dans un
projet fini. C'est donc la seule opération du cycle qui **écrase des données** — le statut d'une
phase bloquée est perdu, et rouvrir le projet ne peut pas le deviner. Elle se confirme à ce titre,
comme une suppression (décision 21), mais **seulement quand il reste quelque chose à écraser** : un
dialogue qui ne prévient de rien apprend à cliquer sans lire.
Engager n'est donc pas un bouton : c'est un geste, celui de donner ses premières dates fermes. Et
écarter n'est pas supprimer — le projet quitte la frise mais reste retrouvable, avec dans ses notes
la raison pour laquelle on a dit non. Les mêmes idées reviennent tous les deux ans, portées par
d'autres : savoir qu'on les a déjà examinées, et sur quel motif, a de la valeur.
### L'horizon est le repli des bornes
Un projet envisagé n'est pas une ligne hors du temps : il porte un **horizon**, exprimé en mois —
« mars 2027 », « de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à
cet horizon. C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et
que six projets pâles s'y superposent, on décide devant l'évidence plutôt que dans l'abstrait.
C'est ce qui remplace le calcul de charge que l'outil ne fera jamais.
La règle tient en une phrase, et elle ne mélange pas le saisi et le calculé :
> L'emprise temporelle d'un projet se calcule depuis ses phases. Un projet qui n'en a pas encore la
> déclare à la main : c'est son horizon.
Les phases gagnent toujours. `bornesProjet()` fait déjà ce calcul et retourne `null` quand il n'y a
aucune phase — l'horizon bouche ce trou, il n'ajoute pas de règle. Corollaire : **on ne réécrit
jamais une saisie par un calcul**. Un projet qui perdrait sa dernière phase retrouve l'horizon qu'il
avait déclaré, pas l'enveloppe des phases disparues.
Le grain est le mois, jamais l'année : en début de mandat, tout est à faire, et un horizon annuel ne
range plus rien. Un horizon s'exprime comme un intervalle de mois plutôt que par des crans nommés —
ni trimestre ni semestre à apprendre, un seul mécanisme, et trois propriétés qui en découlent :
- **la largeur de la barre pâle *est* l'incertitude**, l'œil la lit sans qu'on l'écrive ;
- **la précision de l'horizon suit la maturité du projet** : on resserre à mesure qu'on y voit
clair. Il n'y a pas de frontière entre envisagé et engagé, il y a un dégradé ;
- **engager, c'est préciser au jour près** ce qui était au mois près.
La barre d'un projet envisagé se dessine donc autrement : pâle, aux bords fondus, et **accrochée au
mois** — jamais au jour. C'est l'accroche au lundi de la décision 9 poussée d'un cran : le geste
existe, il ne permet simplement pas de poser une date qu'on n'a pas.
Elle fut d'abord rendue **immobile**, au motif qu'interdire physiquement valait mieux qu'une
convention. L'usage a tranché autrement, et pour une bonne raison : on ajuste un horizon *en
regardant ses voisins*, pour voir si le printemps 2027 est déjà chargé. Obliger à passer par un
panneau pour ce réglage-là, c'est le faire à l'aveugle. L'accroche au mois suffisait à protéger ce
qui devait l'être.
L'horizon **survit à l'engagement**. Non pour le comparer à ce qui a été planifié, mais parce que ne
rien effacer est ici plus simple qu'effacer, et parce qu'il sert de filet si le projet se dépeuple.
### Un horizon échu remonte à la surface
Un backlog échoue toujours de la même façon : tout ce qu'on n'ose pas refuser y atterrit, il gonfle,
plus personne ne le relit. Les deux garde-fous habituels — un plafond, une péremption qui efface —
sont ici interdits par la crainte de départ.
L'horizon en fournit un meilleur : **un projet dont l'horizon est dépassé se signale**. On est en
juin, quatre projets visaient le premier trimestre : il faut trancher, on repousse ou on écarte.
Rien ne disparaît, mais rien ne dort — et la relecture tombe d'elle-même au rythme des arbitrages
budgétaires.
### Ce qui ancre un projet écarté
La question n'a pas la même réponse selon d'où vient l'écartement.
Un projet écarté **depuis l'état engagé** garde ses phases datées, dont certaines ont réellement eu
lieu. Il a un ancrage, et un vrai : le travail fait ne doit pas s'effacer, puisque la frise est notre
mémoire.
Un projet écarté **depuis l'état envisagé** n'a rien. Son horizon a cessé d'être une prévision — on
ne le fera pas à cette date, on ne le fera pas du tout — et l'y laisser serait un mensonge sur la
frise.
D'où **la date d'écartement**, posée automatiquement, jamais saisie. C'est l'ancrage commun aux deux
cas, celui sans lequel les écartés ne forment plus qu'un tas. Il ne s'agit pas de journaliser les
décisions — les comptes rendus de réunion font ce travail, et mieux — mais d'horodater une sortie.
La clôture est horodatée de la même façon, et c'est ce qui donne au modèle sa forme définitive :
**les deux actes prononcés laissent une date, et ces deux dates sont l'unique trace de l'état dans le
fichier.** Acter, c'est dater. Il n'y a pas de champ « état » — il se lit dans ces deux dates et dans
la présence de phases.
On les retrouve d'abord en les cochant dans le filtre par état, ce qui les ramène dans la colonne
des libellés, groupés et éteints. Cela répond à « je sais que ça existe, je veux le retrouver ». Cela
ne répond pas à « relire d'un coup deux ans d'idées écartées avec leurs motifs », qui demanderait une
liste — geste assez rare pour attendre que le manque se fasse sentir.
### La réanimation ne demande aucun mécanisme
Puisque les états se déduisent, il suffit de **retirer l'écartement** : le projet retrouve tout seul
l'état que ses données commandent. Des phases, il redevient engagé ; un horizon seul, envisagé. Pas
de destination à choisir, une seule commande.
Le système se referme bien : un projet écarté en 2026 et réanimé aujourd'hui revient avec un horizon
périmé, donc se signale aussitôt comme échu. On le redate dans la foulée.
```
écarté ←──────┐
↗ ↑ │ réanimer
/ │ │ (l'état se
envisagé ──────┴─ engagé ──→ terminé recalcule)
(un horizon) (≥ 1 phase) (acté)
↑ │
└──────────────┘
s'il perd sa dernière phase
```
### L'état ne passe pas par les tags
Un tag est libre, disparaît quand plus personne ne le porte, et dérive orthographiquement
(décision 16) : trois propriétés inacceptables pour un état. Ce sont deux mécanismes indépendants
qui filtrent la même frise, et les tags restent disponibles pour ce à quoi ils servent.
Les deux filtres ne se comportent d'ailleurs pas pareil, et il faut que cela se voie : **celui des
tags est conjonctif, celui des états est disjonctif**. Un projet porte plusieurs tags mais un seul
état — cocher `client` + `urgent` resserre, cocher `envisagé` + `engagé` élargit. La même case à
cocher voudrait sinon dire deux choses opposées selon la ligne où elle se trouve.
Comme celui des tags, le filtre par état **n'est pas enregistré** : c'est un état de vue, pas un
réglage.
### Ce que le premier essai a corrigé
Trois choses ne se sont vues qu'en manipulant l'outil, et toutes disaient la même : **un projet
envisagé ne doit pas être un citoyen de seconde zone**.
**Engager démarre à l'horizon.** La première phase d'un projet envisagé naissait à la date du jour,
comme celle de n'importe quel projet vide. Elle apparaissait donc à des mois de la barre pâle qu'elle
venait remplacer, et le lien entre les deux se perdait. Elle commence maintenant au premier jour de
l'horizon : engager, c'est préciser au jour près ce qu'on avait déclaré au mois près, pas repartir
d'ailleurs.
**Les deux actes sont dans le panneau, pas seulement dans le menu.** Ils avaient été rangés dans le
menu ⋯ au nom de la décision 20 — un bouton permanent se mérite, et clore un projet est rare. Mais
c'est dans le panneau qu'on **lit** l'état du projet, donc c'est là qu'on cherche à en changer : les
y chercher et ne pas les trouver coûte plus cher que deux boutons de plus. Ils figurent aux deux
endroits, comme « plier » ou « masquer ».
**Les projets sans phase paraissent dans la vue par mois.** Ils n'y figuraient pas, faute de phase à
lister — alors qu'un projet visé pour mars 2027 *arrive* bel et bien en mars 2027. Les omettre
revenait à dire que la liste ne montre que l'engagé, quand toute cette décision pose le contraire.
Ils s'y rattachent au premier mois de leur horizon, dans les mêmes colonnes que les phases mais en
italique. C'est ce qui a fait renommer `phasesParMois` en `entreesParMois` : la liste ne montre plus
seulement des phases.
La règle porte sur **l'absence de phases et non sur l'état**, exactement comme `empriseProjet` : un
projet écarté avant d'avoir jamais été engagé n'a lui non plus que son horizon pour se situer, et
il doit se retrouver là quand on ouvre le cimetière. La distinguer par l'état aurait fait du
cimetière de la liste par mois une collection des seuls projets qui avaient démarré — soit
l'inverse de ce qu'on y cherche.
### Ce qui a été reporté, et pourquoi
Ces choix ne sont pas des refus définitifs. Ils attendent leur moment, et il vaut mieux savoir
lequel.
**La matrice d'Eisenhower.** Elle n'a pas de sens sur des projets engagés, pour les raisons
ci-dessus. Sur les seuls envisagés elle redevient cohérente, et se branche même bien : l'horizon
fournit l'axe d'urgence, il ne resterait que l'importance à saisir. À reprendre quand une liste
existera, et seulement si l'horizon seul ne suffit pas à départager.
**Une vue liste des envisagés.** La frise sait situer, elle ne sait pas ranger : on ne peut pas
ordonner six projets par priorité dans un empilement de couloirs. Cette première étape ne livre donc
**aucun mécanisme d'ordre explicite entre projets** — ce qu'elle livre, c'est l'horizon, et c'est
déjà l'arbitrage le plus décisif. Reste le départage fin entre projets visant le même trimestre.
**Un dézoom de l'axe temporel**, pour embrasser plusieurs années d'un coup. Le jour où il arrivera,
il faudra rouvrir la décision 14, « une seule échelle, pas de zoom ».
**Une vue de rétrospective.** La frise défile en arrière sans limite (décision 15) : le passé y est
déjà lisible, et un écran de plus l'aurait redit. Corollaire à tenir : **un projet terminé reste sur
la frise**, éteint mais présent, sinon on perd le bilan.
**Des dates réelles à côté des dates prévues.** Ce serait doubler chaque champ pour un gain nul :
dans cet outil on recale les barres au fil de l'eau, donc la date de fin *devient* le réel. La
contrepartie est assumée — la qualité du bilan dépend de l'entretien de la frise, et aucun champ ne
répare un planning qu'on ne tient pas.
**Un repère de fin de mandat**, comme le repère « aujourd'hui ». Pertinent sur le principe, sans
urgence en début de mandat.
**Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.