Files
gestion_projets/docs/decisions.md
Bertrand Benjamin ff147a17b9 Rendre les notes en markdown, et en donner au projet
Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.

Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.

Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).

La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.

Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.

Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:35:17 +02:00

43 KiB
Raw Blame History

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 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.valuevide 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.

Les infobulles s'aplatissent

Les infobulles de la frise 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.