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>
This commit is contained in:
2026-08-07 18:35:17 +02:00
parent e04877daae
commit ff147a17b9
16 changed files with 1150 additions and 35 deletions

View File

@@ -110,6 +110,28 @@ Une fois un projet en place :
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
### Les notes
Un projet **et** une phase portent chacun des notes, en bas de leur panneau. Elles y prennent toute
la hauteur restante : les autres champs ont une taille dictée par leur contenu, une note fait ce
qu'on a à dire.
Le texte s'écrit en **markdown** — titres, `**gras**`, `*italique*`, listes, `- [ ] cases à
cocher`, liens, citations, blocs de code. Le bloc s'ouvre sur l'**aperçu** quand la note existe
déjà, sur la saisie quand elle est vide ; le crayon en haut à droite bascule d'un état à l'autre,
et une barre de six boutons paraît en saisie (`Ctrl`+`B` et `Ctrl`+`I` marchent aussi). Rien n'est
converti à l'enregistrement : le fichier contient le markdown tel quel, lisible et modifiable à la
main.
Les **cases à cocher se cliquent directement dans l'aperçu** — c'est le seul geste de l'aperçu qui
change les données. Cocher une ligne ne touche à rien d'autre : votre mise en forme n'est jamais
réécrite.
Les notes d'un projet sont ce que la frise ne sait pas dire — qui pilote, sur quel budget, quelle
décision à quelle date. Elles se lisent aussi au survol du nom du projet dans la colonne de gauche,
et celles d'une phase au survol de sa barre : l'infobulle les affiche sans les marques de mise en
forme, puces et cases dessinées comprises.
### La vue par mois
Les deux boutons **Frise** et **Par mois**, en haut à gauche, changent de regard sur les mêmes
@@ -125,12 +147,13 @@ n'apparaît qu'à son mois de départ, avec sa date de fin écrite en clair.
Les phases qui portent des **notes** le signalent par un triangle en tête de ligne, le même que
celui qui plie un projet sur la frise. Un clic **n'importe où sur la ligne** déplie la note en
dessous, un second la referme. Rien n'est affiché d'office — trois lignes de notes sous chaque
intitulé étaleraient un mois sur deux écrans.
dessous, un second la referme. Elle s'y affiche rendue, comme dans le panneau. Rien n'est affiché
d'office — trois lignes de notes sous chaque intitulé étaleraient un mois sur deux écrans.
Cette liste se **lit** — elle ne s'édite pas. Déplacer une phase demande de voir ce qu'elle
chevauche, donc la frise ; c'est là que les dates et les notes se modifient. Seuls les tags et les
notes y sont cliquables, et ni filtrer ni déplier ne touche aux données. Le bouton **Aujourd'hui**
notes y sont cliquables, et ni filtrer ni déplier ne touche aux données — les cases à cocher d'une
note y sont d'ailleurs inertes, elles ne se cochent que depuis le panneau. Le bouton **Aujourd'hui**
ramène ici sur le mois en cours, et la vue affichée n'est pas mémorisée : l'outil s'ouvre toujours
sur la frise.
@@ -156,7 +179,9 @@ par les champs du panneau de détail.
## Ce que fait l'outil
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
un statut, des notes libres. Une phase peut être un **jalon** (une date unique, rendue en losange).
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange).
- **Des notes en markdown**, sur un projet comme sur une phase : titres, listes, cases à cocher,
liens. Elles occupent toute la hauteur restante du panneau, et se cochent d'un clic dans l'aperçu.
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle ne se
@@ -252,8 +277,10 @@ données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes, ancrage sur le mois courant |
| `js/tags.js` | Pastilles de tags, partagées par les deux vues |
| `js/drag.js` | Glisser et redimensionner les barres |
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
| `js/detail.js` | Panneau de détail d'une phase |
| `js/projet.js` | Panneau des paramètres d'un projet : nom, couleur, tags |
| `js/projet.js` | Panneau des paramètres d'un projet : nom, couleur, tags, notes |
| `js/menu.js` | Menu contextuel et dialogue de confirmation |
| `js/app.js` | Amorçage, état en mémoire, câblage des événements |

View File

@@ -855,7 +855,13 @@ button[aria-pressed="true"] {
background: none;
}
/* `flex: 1` et `min-height: 0` : le corps prend toute la hauteur sous l'en-tête,
et le second garde le droit de rétrécir sous la taille de son contenu — sans
lui, un flex item refuse de descendre sous sa hauteur intrinsèque et c'est la
page entière qui déborderait au lieu du corps qui défile. */
.panneau__corps {
flex: 1;
min-height: 0;
padding: 16px;
overflow-y: auto;
display: flex;
@@ -920,6 +926,290 @@ button[aria-pressed="true"] {
margin-top: 4px;
}
/* --- bloc de notes -------------------------------------------------------- */
/* Le bloc prend toute la hauteur que les champs au-dessus lui laissent. C'est
ce qui distingue une note d'un champ : les autres ont une taille dictée par
ce qu'ils contiennent — une date, un statut —, une note n'en a aucune, et
quatre lignes fixes étaient un plafond arbitraire qui obligeait à écrire dans
une meurtrière. Le plancher, lui, reste : sur une fenêtre basse, le corps du
panneau défile plutôt que d'écraser la note à deux lignes. */
.notes {
flex: 1 1 auto;
min-height: 190px;
display: flex;
flex-direction: column;
gap: 6px;
}
.notes__entete {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
}
.notes__intitule {
font-size: 12px;
font-weight: 500;
color: var(--texte-doux);
}
/* La barre d'outils n'apparaît qu'en saisie : en aperçu elle ne commanderait
rien, et sa place revient au texte. */
.notes__outils {
display: flex;
gap: 2px;
margin-left: auto;
}
.notes__outils button {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--rayon);
background: none;
color: var(--texte-doux);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.notes__outils button:hover {
border-color: var(--bordure-forte);
color: var(--texte);
}
.notes__outils [data-outil="gras"] {
font-weight: 700;
}
.notes__outils [data-outil="italique"] {
font-style: italic;
font-family: Georgia, serif;
}
/* Le crayon reste à l'extrémité droite quelle que soit la présence de la barre
d'outils : c'est le seul bouton toujours là, il lui faut une place fixe. */
.notes__bascule {
margin-left: auto;
width: 26px;
height: 24px;
padding: 0;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--surface);
color: var(--texte-doux);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.notes__outils:not([hidden]) + .notes__bascule {
margin-left: 4px;
}
.notes__bascule:hover {
color: var(--texte);
border-color: var(--texte-doux);
}
.notes__apercu {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 10px;
border: 1px solid var(--bordure);
border-radius: var(--rayon);
background: var(--fond);
font-size: 13px;
line-height: 1.5;
cursor: text;
}
.notes__invite {
margin: 0;
color: var(--texte-doux);
font-style: italic;
}
/* `resize: none` contre le `resize: vertical` des autres textarea du panneau :
celui-ci occupe la place disponible, une poignée de redimensionnement dans
son coin ne ferait que se battre avec la poignée du volet. */
.notes__saisie {
flex: 1;
min-height: 0;
resize: none;
font-size: 13px;
line-height: 1.5;
tab-size: 2;
}
/* --- rendu markdown ------------------------------------------------------- */
/* Ces règles servent au panneau comme à la vue par mois : une note doit se lire
pareil des deux côtés, sans quoi on douterait d'avoir la même sous les yeux.
Les marges sont serrées — c'est un encart dans un volet, pas un article. */
/* Le style suit `data-niveau` — le nombre de dièses écrits — et non la balise :
celle-ci est décalée de deux rangs pour ne pas casser la hiérarchie du
document, et son plafond à `h6` confond les rangs 4 à 6. Les niveaux se
séparent surtout par le blanc au-dessus : dans un encart de treize pixels,
deux corps de titre distants d'un pixel ne se distinguent pas, alors qu'un
interligne plus large se voit tout de suite. */
.md-titre {
margin: 12px 0 4px;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
}
.md-titre[data-niveau="1"] {
font-size: 16px;
margin-top: 16px;
}
.md-titre[data-niveau="2"] {
font-size: 14px;
margin-top: 14px;
}
/* Au-delà du troisième rang, le titre ne grossit plus : il s'efface. C'est un
intertitre dans un encart, pas une section — et une note qui descend à cinq
niveaux a de toute façon perdu la partie. */
.md-titre[data-niveau="4"],
.md-titre[data-niveau="5"],
.md-titre[data-niveau="6"] {
color: var(--texte-doux);
}
.md-titre:first-child {
margin-top: 0;
}
.md-paragraphe {
margin: 0 0 8px;
}
.md-paragraphe:last-child {
margin-bottom: 0;
}
.md-liste {
margin: 0 0 8px;
padding-left: 20px;
}
.md-liste:last-child {
margin-bottom: 0;
}
.md-liste li {
margin-bottom: 2px;
}
/* La case *est* la puce : les deux ensemble décaleraient le texte sans rien
ajouter. */
.md-liste--taches {
list-style: none;
padding-left: 2px;
}
/* Une sous-liste de tâches perdrait sinon toute indentation, son retrait ne
venant que de la puce qu'on vient de lui retirer. Or l'imbrication est
justement ce qu'on est venu lire : « variante sombre » dépend de « relancer
sur les maquettes », et rien d'autre que le décalage ne le dit. */
.md-liste .md-liste--taches {
padding-left: 20px;
}
/* `.panneau__corps label` empile ses enfants en colonne et les grise — c'est ce
qu'il faut d'un champ de formulaire, dont l'intitulé surmonte la saisie. Une
case à cocher dans une note n'est pas cela : la case précède son texte sur la
même ligne, et ce texte est du contenu, pas une étiquette. D'où la
spécificité rehaussée, seule façon de reprendre la main sur une règle qui
vise tous les labels du panneau. */
.notes__apercu .md-tache,
.mois-entree__notes .md-tache {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 400;
color: inherit;
cursor: pointer;
}
/* `width: auto` contre la règle qui étire à 100 % les champs du panneau : une
case à cocher garde sa taille native. */
.md-tache input {
margin: 3px 0 0;
width: auto;
flex-shrink: 0;
}
/* Une tâche faite s'efface sans disparaître : elle compte encore dans ce qui a
été décidé, elle ne compte plus dans ce qui reste. */
.md-tache--faite {
color: var(--texte-doux);
text-decoration: line-through;
}
/* Les cases de la vue par mois sont désactivées (lecture seule, décision 24) :
sans cela, le curseur y promettrait un clic sans effet. */
.md-tache input:disabled {
cursor: default;
}
.md-tache:has(input:disabled) {
cursor: default;
}
.md-citation {
margin: 0 0 8px;
padding-left: 10px;
border-left: 3px solid var(--bordure-forte);
color: var(--texte-doux);
}
.md-citation > :last-child {
margin-bottom: 0;
}
.md-code {
margin: 0 0 8px;
padding: 6px 8px;
overflow-x: auto;
border-radius: var(--rayon);
background: color-mix(in srgb, var(--texte) 7%, transparent);
font-size: 12px;
}
.md-code-ligne {
padding: 1px 4px;
border-radius: 3px;
background: color-mix(in srgb, var(--texte) 7%, transparent);
font-size: 0.92em;
}
.md-titre + .md-liste,
.md-titre + .md-paragraphe {
margin-top: 0;
}
.notes__apercu hr,
.mois-entree__notes hr {
margin: 10px 0;
border: 0;
border-top: 1px solid var(--bordure-forte);
}
.notes__apercu a,
.mois-entree__notes a {
color: var(--accent);
}
/* --- menu contextuel ------------------------------------------------------ */
.menu {
@@ -1269,10 +1559,12 @@ button.mois-entree__plier {
marque — les colonnes sont nommées par leur rang, ce qui laisse la grille
faire le calcul, là où un retrait en pixels aurait recopié des largeurs et se
serait désaccordé à la première retouche. */
/* Le `pre-wrap` d'autrefois n'a plus lieu d'être : les retours à la ligne sont
désormais portés par le rendu markdown, qui les traduit en `<br>` et en
éléments de liste. */
.mois-entree__notes {
grid-column: 3 / -1;
margin: 2px 0 6px;
white-space: pre-wrap;
color: var(--texte-doux);
}

View File

@@ -1,5 +1,5 @@
{
"version": 3,
"version": 4,
"projects": [
{
"id": "site-web",
@@ -8,6 +8,7 @@
"tags": ["client", "web"],
"collapsed": false,
"hidden": false,
"notes": "## Contexte\n\nPiloté par **Marie D.**, budget voté en avril.\n\n> Le prestataire est engagé jusqu'au 31 janvier 2027.\n\n### À suivre\n\n- [x] cadrage validé en comité\n- [ ] relancer sur les maquettes\n - [ ] variante sombre\n- [ ] prévoir la reprise des anciennes URL\n",
"baselineDate": "2026-06-12",
"phases": [
{
@@ -17,7 +18,7 @@
"end": "2026-07-10",
"status": "done",
"milestone": false,
"notes": "Ateliers avec les trois pôles. Périmètre arrêté le 8 juillet.",
"notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.",
"baseline": { "start": "2026-06-15", "end": "2026-07-03" }
},
{
@@ -69,6 +70,7 @@
"tags": ["infra", "interne"],
"collapsed": false,
"hidden": false,
"notes": "Chantier interne, sans budget propre.\n\nContact hébergeur : [support](mailto:support@example.org)\n",
"phases": [
{
"id": "audit",
@@ -95,7 +97,7 @@
"end": "2026-09-11",
"status": "blocked",
"milestone": false,
"notes": "En attente de la fenêtre de maintenance côté hébergeur."
"notes": "**Bloquée** : en attente de la fenêtre de maintenance côté hébergeur.\n\n- [x] demande déposée le 3 août\n- [ ] créneau confirmé\n"
},
{
"id": "bascule-generale",
@@ -124,6 +126,7 @@
"tags": ["interne", "RH"],
"collapsed": true,
"hidden": false,
"notes": "",
"phases": [
{
"id": "recensement",

View File

@@ -601,3 +601,111 @@ revenir des années avant ce qu'on regardait serait une punition pour avoir cons
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.
### 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.

View File

@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
```json
{
"version": 3,
"version": 4,
"projects": [
{
"id": "site-web",
@@ -18,6 +18,7 @@ dans un diff git et éditable à la main.
"tags": ["client", "web"],
"collapsed": false,
"hidden": false,
"notes": "## Contexte\n\nPiloté par **Marie D.**\n\n- [x] cadrage validé\n- [ ] recette",
"baselineDate": "2026-06-12",
"phases": [
{
@@ -27,7 +28,7 @@ dans un diff git et éditable à la main.
"end": "2026-08-20",
"status": "done",
"milestone": false,
"notes": "Ateliers avec les trois pôles.",
"notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.",
"baseline": { "start": "2026-07-25", "end": "2026-08-10" }
},
{
@@ -49,11 +50,17 @@ dans un diff git et éditable à la main.
| Champ | Type | Description |
|---|---|---|
| `version` | entier | Version du format. Vaut `3`. Sert à détecter un fichier trop ancien au chargement. |
| `version` | entier | Version du format. Vaut `4`. Sert à détecter un fichier trop ancien au chargement. |
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
Un fichier en version `2` — celle d'avant les tags — se charge sans rien demander : ses projets
reçoivent une liste de tags vide, et il est réécrit en version `3` à la première sauvegarde.
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
vide, une version `3` — celle d'avant les notes de projet — des notes vides.
Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
pas, si bien qu'un binaire antérieur ouvrirait sans broncher un fichier plus riche que lui et en
effacerait les champs inconnus à la première sauvegarde.
## Projet
@@ -65,6 +72,7 @@ reçoivent une liste de tags vide, et il est réécrit en version `3` à la prem
| `tags` | tableau | Étiquettes libres servant à filtrer la frise. Trié, sans doublon, éventuellement vide. |
| `collapsed` | booléen | `true` si le couloir est plié. |
| `hidden` | booléen | `true` si le projet est masqué de la frise. Ses données restent intactes. |
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. Ce que la frise ne sait pas dire : contexte, interlocuteurs, décisions. |
| `baselineDate` | chaîne ou absent | Date à laquelle la référence a été figée. Absent si elle ne l'a jamais été. |
| `phases` | tableau | Les phases, triées par date de début croissante. |
@@ -78,7 +86,7 @@ reçoivent une liste de tags vide, et il est réécrit en version `3` à la prem
| `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. |
| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. |
| `milestone` | booléen | `true` pour un jalon. |
| `notes` | chaîne | Texte libre, éventuellement vide. |
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. |
| `baseline` | objet ou absent | Dates de référence : `{ "start": …, "end": … }`. Absent tant que la référence n'a pas été figée. |
## Règles de validation
@@ -99,6 +107,9 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
- `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés —
un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche,
est normalisé sans erreur (voir ci-dessous).
- `notes`, de projet comme de phase, est ramenée à `""` si ce n'est pas une chaîne. Le champ n'engage
aucun calcul, contrairement aux dates : bloquer le chargement d'un planning entier pour lui serait
disproportionné.
## Tags
@@ -130,6 +141,43 @@ temporelle. Voir [decisions.md](decisions.md), section 16.
Un fichier invalide n'est jamais réparé en silence : le chargement échoue avec un message qui pointe
le projet et la phase fautifs, pour que le fichier puisse être corrigé à la main.
## Notes
Un projet et une phase portent chacun un champ `notes`, en **markdown**. Rien n'est stocké d'autre
que le texte source : le rendu est refait à l'affichage par `js/markdown.js` (analyse) et
`js/notes.js` (mise en DOM). Un fichier reste donc lisible et modifiable à la main, et une note
écrite hors de l'outil s'affiche sans conversion.
Le markdown reconnu est un **sous-ensemble**, choisi pour ce qu'on écrit dans une note de suivi :
| Syntaxe | Rendu |
|---|---|
| `# Titre` à `###### Titre` | Titres, décalés de deux niveaux sous celui du panneau |
| `**gras**`, `*italique*`, `_italique_` | Emphase |
| `` `code` `` et blocs ```` ``` ```` | Code littéral |
| `- item`, `* item`, `1. item` | Listes, imbricables à l'indentation |
| `- [ ] item`, `- [x] item` | Cases à cocher, cliquables dans le panneau |
| `[texte](https://…)` | Lien |
| `> cité` | Citation, réanalysée (elle peut contenir une liste) |
| `---` | Séparateur |
| `\*` | Le caractère littéral qui suit la contre-oblique |
Trois écarts assumés au markdown standard :
- **Un retour à la ligne en est un.** Le standard recolle les lignes d'un même paragraphe et exige
deux espaces en fin de ligne pour un vrai retour ; c'est un piège invisible dans un champ de
saisie, où l'on écrit souvent une petite liste à la volée.
- **Ce qui n'est pas reconnu reste du texte.** `**gras` sans fermeture s'affiche tel quel plutôt que
d'avaler la suite : une note à moitié écrite est l'état normal d'une note.
- **Ni tableaux, ni images, ni HTML brut.** Les deux premiers ne tiennent pas dans un volet latéral ;
le troisième rouvrirait la porte à l'injection que la construction du DOM nœud par nœud ferme.
Les **liens** ne sont suivis que si leur schéma est `http`, `https` ou `mailto` — liste blanche, et
non liste noire. Un planning s'échange, et rien ne garantit que la note affichée a été écrite par
celui qui la lit. Un lien refusé n'est pas effacé : son libellé redevient du texte.
Voir [decisions.md](decisions.md), section 25.
## Dates : conventions
Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite

View File

@@ -142,10 +142,12 @@
Jalon (une seule date, affichée en losange)
</label>
<label>
Notes
<textarea name="notes" rows="4"></textarea>
</label>
<!--
Les notes sont bâties par `notes.js` : aperçu markdown, barre d'outils
et zone de saisie, le tout identique dans les deux panneaux. Un seul
point d'ancrage ici plutôt que deux fois la même douzaine de balises.
-->
<section id="notes-phase"></section>
<p class="panneau__erreur" id="erreur-phase" role="alert" hidden></p>
@@ -197,6 +199,13 @@
<div class="tags tags--suggestions" id="panneau-projet-suggestions"></div>
</fieldset>
<!--
Notes du projet : ce que la frise ne sait pas dire. Le contexte d'un
engagement, un interlocuteur, une décision et sa date. Elles occupent
le bas du volet, là où celles d'une phase occupent le bas du sien.
-->
<section id="notes-projet"></section>
<p class="panneau__erreur" id="erreur-projet" role="alert" hidden></p>
<div class="panneau__actions">

View File

@@ -139,6 +139,7 @@ const panneau = creerPanneau(
titre: $('panneau-titre'),
erreur: $('erreur-phase'),
champFin: $('champ-fin'),
notes: $('notes-phase'),
supprimer: $('supprimer-phase'),
fermer: $('fermer-panneau'),
},
@@ -179,6 +180,7 @@ const panneauProjet = creerPanneauProjet(
conteneurCouleurs: $('panneau-projet-couleurs'),
suggestions: $('panneau-projet-suggestions'),
blocSuggestions: $('panneau-projet-suggestions-bloc'),
notes: $('notes-projet'),
supprimer: $('supprimer-projet'),
fermer: $('fermer-panneau-projet'),
},

View File

@@ -14,9 +14,16 @@
*/
import { STATUTS, dateValide, modifierPhase } from './model.js';
import { creerBlocNotes } from './notes.js';
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champFin, supprimer, fermer } = refs;
const { panneau, formulaire, titre, erreur, champFin, notes, supprimer, fermer } = refs;
// Le bloc pose lui-même un `<textarea name="notes">` dans le formulaire, et
// émet un `input` à chaque changement — bouton de la barre d'outils ou case
// cochée comprises. Le reste du panneau continue donc de lire son formulaire
// sans rien savoir du markdown.
const blocNotes = creerBlocNotes(notes, { libelle: 'Notes' });
let selection = null; // { projet, phase }
let phaseCourante = null;
@@ -33,7 +40,10 @@ export function creerPanneau(refs, rappels) {
formulaire.end.value = phase.end;
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
formulaire.milestone.checked = phase.milestone;
formulaire.notes.value = phase.notes;
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
// y écrire quelque chose.
blocNotes.definir(phase.notes, { reinitialiserMode: true });
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
champFin.hidden = phase.milestone;
@@ -152,9 +162,13 @@ export function creerPanneau(refs, rappels) {
const actif = document.activeElement;
phaseCourante = phase;
titre.textContent = `${projet.name}${phase.name}`;
for (const champ of ['name', 'start', 'end', 'status', 'notes']) {
for (const champ of ['name', 'start', 'end', 'status']) {
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
}
// Le mode n'est pas réinitialisé : basculer en aperçu sous les doigts de
// qui écrit, parce qu'un glisser sur la frise a rafraîchi le panneau,
// serait le pire moment pour changer d'état.
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
champFin.hidden = phase.milestone;
},

View File

@@ -12,7 +12,18 @@
* La date de fin est *incluse* : du 01/08 au 01/08 dure un jour.
*/
export const VERSION_FORMAT = 3;
/**
* Version du format de fichier.
*
* Elle a changé pour la dernière fois quand le projet a reçu des `notes` (v4).
* Un champ ajouté n'oblige pourtant à rien : la v3 se relit sans encombre, et
* le validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement
* là qu'est la raison d'incrémenter — le validateur reconstruit chaque projet
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
* toutes les notes de projet à la première sauvegarde.
*/
export const VERSION_FORMAT = 4;
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
@@ -347,6 +358,11 @@ export function validerPlanning(donnees) {
tags: validerTags(projet.tags, repere),
collapsed: Boolean(projet.collapsed),
hidden: Boolean(projet.hidden),
// Absentes d'un fichier en version 3, et ramenées à vide si elles ne sont
// pas du texte : une note est de la prose, comme celles d'une phase, et
// bloquer le chargement d'un planning entier pour un champ mal typé serait
// disproportionné là où le champ n'engage aucun calcul.
notes: typeof projet.notes === 'string' ? projet.notes : '',
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
phases: trierPhases(phases),
};
@@ -616,6 +632,7 @@ export function creerProjet(planning, nom, tags = []) {
tags: normaliserTags(tags),
collapsed: false,
hidden: false,
notes: '',
phases: [],
};
}

View File

@@ -29,6 +29,8 @@ import {
phasesParMois,
projetFiltre,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { rendreMarkdown } from './notes.js';
import { pastillesTags } from './tags.js';
/**
@@ -218,14 +220,20 @@ function triangle(phase) {
/**
* Les notes occupent une seconde rangée de la grille, sous toutes les colonnes.
* `white-space: pre-wrap` (style.css) leur conserve leurs retours à la ligne :
* elles sont souvent une petite liste, et la mettre à plat en changerait le
* sens.
*
* Elles sont rendues en markdown, comme dans le panneau : une note doit se lire
* pareil des deux côtés, sans quoi on douterait d'avoir la même sous les yeux.
*
* Les cases à cocher, elles, restent **inertes** ici. La vue 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.
*/
function notes(phase) {
const bloc = document.createElement('p');
const bloc = document.createElement('div');
bloc.className = 'mois-entree__notes';
bloc.textContent = phase.notes;
bloc.append(rendreMarkdown(phase.notes));
bloc.hidden = true;
return bloc;
}
@@ -239,7 +247,10 @@ function infobulle(projet, phase) {
: `${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
` (${jours} jour${jours > 1 ? 's' : ''})`) +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '')
// Aplati : un `title` ne connaît que le texte. Sans cela, survoler afficherait
// la source markdown, astérisques comprises — une note *moins* lisible
// qu'avant qu'on ne l'enrichisse.
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '')
);
}

533
js/notes.js Normal file
View File

@@ -0,0 +1,533 @@
/**
* Notes en markdown : rendu et bloc d'édition.
*
* Deux choses ici, qui vont ensemble. Le **rendu** d'abord — `rendreMarkdown`
* transforme l'arbre de `markdown.js` en DOM, et sert aussi bien au panneau
* qu'à la vue par mois. Le **bloc de notes** ensuite : l'aperçu, la barre
* d'outils et la zone de saisie que les deux panneaux se partagent.
*
* **Aucun `innerHTML`.** Le DOM est bâti nœud par nœud. Un planning s'échange —
* on en reçoit un par courriel, on en ouvre un que quelqu'un d'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 sont
* filtrées en amont par `urlSure`.
*
* **On lit d'abord, on écrit ensuite.** Le bloc s'ouvre sur l'aperçu quand la
* note existe déjà, sur la saisie quand elle est vide. C'est l'usage : on ouvre
* une phase commentée pour relire pourquoi elle est bloquée, et une phase
* vierge pour y consigner quelque chose. Le crayon bascule d'un état à l'autre.
*/
import { analyserMarkdown, basculerCase } from './markdown.js';
// ---------------------------------------------------------------------------
// Rendu
// ---------------------------------------------------------------------------
/**
* Rend une note en fragment de DOM.
*
* @param {string} texte la note, en markdown.
* @param {object} options
* - casesActives : rend les cases à cocher cliquables. Faux ailleurs que dans
* le panneau — la vue par mois est en lecture seule (décision 24), et une
* case cochable y serait la seule chose qu'on puisse y modifier.
* @returns {DocumentFragment}
*/
export function rendreMarkdown(texte, { casesActives = false } = {}) {
const fragment = document.createDocumentFragment();
peindreBlocs(analyserMarkdown(texte), fragment, casesActives);
return fragment;
}
function peindreBlocs(blocs, parent, casesActives) {
for (const bloc of blocs) {
switch (bloc.type) {
case 'titre': {
// La balise est décalée de deux rangs : le titre du panneau est un
// `h2`, et une note ne peut pas ouvrir une section de rang supérieur à
// ce qui la contient. Mais le **style** suit `data-niveau`, c'est-à-dire
// le nombre de dièses écrits, et non la balise : le plafond à `h6`
// écrase les rangs 4 à 6 sur une même balise, et styler par balise
// rendait `#` plus petit que `##` — soit exactement l'inverse de ce que
// l'auteur de la note a demandé.
const titre = document.createElement(`h${Math.min(6, bloc.niveau + 2)}`);
titre.className = 'md-titre';
titre.dataset.niveau = String(bloc.niveau);
peindreInline(bloc.contenu, titre);
parent.append(titre);
break;
}
case 'paragraphe': {
const paragraphe = document.createElement('p');
paragraphe.className = 'md-paragraphe';
peindreInline(bloc.contenu, paragraphe);
parent.append(paragraphe);
break;
}
case 'liste':
parent.append(peindreListe(bloc, casesActives));
break;
case 'citation': {
const citation = document.createElement('blockquote');
citation.className = 'md-citation';
peindreBlocs(bloc.blocs, citation, casesActives);
parent.append(citation);
break;
}
case 'code': {
const preforme = document.createElement('pre');
preforme.className = 'md-code';
const code = document.createElement('code');
code.textContent = bloc.lignes.join('\n');
preforme.append(code);
parent.append(preforme);
break;
}
case 'separateur':
parent.append(document.createElement('hr'));
break;
}
}
}
function peindreListe(bloc, casesActives) {
const liste = document.createElement(bloc.ordonnee ? 'ol' : 'ul');
liste.className = 'md-liste';
// Une liste de cases à cocher perd sa puce : la case *est* la puce, et les
// deux côte à côte décalent le texte sans rien ajouter.
if (bloc.elements.some((element) => element.coche !== null)) {
liste.classList.add('md-liste--taches');
}
for (const element of bloc.elements) {
const item = document.createElement('li');
if (element.coche === null) {
peindreInline(element.contenu, item);
} else {
item.append(peindreTache(element, casesActives));
}
peindreBlocs(element.blocs, item, casesActives);
liste.append(item);
}
return liste;
}
/**
* Élément de liste porteur d'une case.
*
* La case et son texte sont dans un même `<label>` : toute la ligne devient
* cliquable, ce qui vaut mieux qu'une cible de treize pixels de côté. Le numéro
* de ligne voyage dans un `data-` — c'est lui que le bloc de notes rendra à
* `basculerCase` pour réécrire la bonne ligne de la note.
*/
function peindreTache(element, casesActives) {
const etiquette = document.createElement('label');
etiquette.className = 'md-tache';
const coche = document.createElement('input');
coche.type = 'checkbox';
coche.checked = element.coche;
coche.dataset.ligne = String(element.ligne);
coche.disabled = !casesActives;
etiquette.append(coche);
const texte = document.createElement('span');
if (element.coche) texte.className = 'md-tache--faite';
peindreInline(element.contenu, texte);
etiquette.append(texte);
return etiquette;
}
function peindreInline(noeuds, parent) {
for (const noeud of noeuds) {
switch (noeud.type) {
case 'texte':
parent.append(document.createTextNode(noeud.valeur));
break;
case 'saut':
parent.append(document.createElement('br'));
break;
case 'gras': {
const gras = document.createElement('strong');
peindreInline(noeud.contenu, gras);
parent.append(gras);
break;
}
case 'italique': {
const italique = document.createElement('em');
peindreInline(noeud.contenu, italique);
parent.append(italique);
break;
}
case 'code': {
const code = document.createElement('code');
code.className = 'md-code-ligne';
code.textContent = noeud.valeur;
parent.append(code);
break;
}
case 'lien': {
const lien = document.createElement('a');
lien.href = noeud.url;
// Le planning s'ouvre dans un onglet où l'on travaille : suivre un lien
// ne doit pas le remplacer. `noopener` coupe l'accès de la page ouverte
// à celle-ci, `noreferrer` lui tait d'où l'on vient.
lien.target = '_blank';
lien.rel = 'noopener noreferrer';
lien.title = noeud.url;
peindreInline(noeud.contenu, lien);
parent.append(lien);
break;
}
}
}
}
// ---------------------------------------------------------------------------
// Bloc de notes
// ---------------------------------------------------------------------------
/**
* Barre d'outils, volontairement courte.
*
* Six boutons pour ce qu'on écrit vraiment dans une note de suivi. Le markdown
* complet est accessible au clavier — c'est du texte —, la barre ne sert qu'à
* ne pas avoir à se rappeler la syntaxe des quatre ou cinq formes courantes.
* Une barre exhaustive prendrait deux rangées dans un volet de 340 pixels et
* repousserait la note d'autant.
*/
const OUTILS = [
{ cle: 'gras', libelle: 'B', titre: 'Gras (Ctrl+B)', entoure: '**' },
{ cle: 'italique', libelle: 'I', titre: 'Italique (Ctrl+I)', entoure: '*' },
{ cle: 'titre', libelle: 'H', titre: 'Titre', prefixe: '## ' },
{ cle: 'liste', libelle: '•', titre: 'Liste à puces', prefixe: '- ' },
{ cle: 'tache', libelle: '☑', titre: 'Case à cocher', prefixe: '- [ ] ' },
{ cle: 'lien', libelle: '🔗', titre: 'Lien', lien: true },
];
/**
* Construit le bloc de notes dans son hôte et renvoie de quoi le piloter.
*
* @param {HTMLElement} hote conteneur, vidé puis rempli.
* @param {object} options
* - nom : attribut `name` de la zone de saisie. Le bloc vit dans un `<form>`,
* et c'est ce nom qui le rend lisible par `formulaire.notes.value` — les
* deux panneaux continuent donc de lire leur formulaire comme avant.
* - libelle : intitulé affiché.
*
* Toute modification — frappe, bouton de la barre, case cochée — émet un
* `input` qui remonte au formulaire. Les panneaux appliquent déjà les
* changements au fil de la saisie : ils n'ont rien à savoir de ce bloc.
*/
export function creerBlocNotes(hote, { nom = 'notes', libelle = 'Notes' } = {}) {
hote.classList.add('notes');
hote.replaceChildren();
const entete = document.createElement('div');
entete.className = 'notes__entete';
const intitule = document.createElement('span');
intitule.className = 'notes__intitule';
intitule.textContent = libelle;
entete.append(intitule);
const outils = document.createElement('div');
outils.className = 'notes__outils';
outils.hidden = true;
entete.append(outils);
const bascule = document.createElement('button');
bascule.type = 'button';
bascule.className = 'notes__bascule';
entete.append(bascule);
const apercu = document.createElement('div');
apercu.className = 'notes__apercu';
const champ = document.createElement('textarea');
champ.className = 'notes__saisie';
champ.name = nom;
champ.hidden = true;
champ.spellcheck = true;
hote.append(entete, apercu, champ);
let enEdition = false;
function afficherMode() {
outils.hidden = !enEdition;
champ.hidden = !enEdition;
apercu.hidden = enEdition;
bascule.textContent = enEdition ? '👁' : '✎';
bascule.title = enEdition ? 'Aperçu' : 'Modifier';
bascule.setAttribute('aria-label', bascule.title);
bascule.setAttribute('aria-pressed', String(enEdition));
}
function peindreApercu() {
apercu.replaceChildren();
if (!champ.value.trim()) {
// Un aperçu vide n'apprend rien et n'offre aucune prise. Cette invite est
// à la fois le message et le bouton : cliquer n'importe où dans la zone
// vide ouvre la saisie, ce qui est le seul geste qu'on puisse y vouloir.
const invite = document.createElement('p');
invite.className = 'notes__invite';
invite.textContent = 'Aucune note — cliquer pour écrire.';
apercu.append(invite);
return;
}
apercu.append(rendreMarkdown(champ.value, { casesActives: true }));
}
/** Signale au formulaire hôte que la note a changé. */
function signaler() {
champ.dispatchEvent(new Event('input', { bubbles: true }));
}
function passerEnEdition({ focus = true } = {}) {
enEdition = true;
afficherMode();
if (focus) champ.focus();
}
bascule.addEventListener('click', () => {
if (enEdition) {
enEdition = false;
peindreApercu();
afficherMode();
} else {
passerEnEdition();
}
});
// Cliquer dans le vide de l'aperçu ouvre la saisie ; cliquer un lien ou une
// case, non — ces deux-là ont déjà leur propre effet, et basculer en édition
// sous le doigt de qui vient de cocher serait une trahison du geste.
apercu.addEventListener('click', (evenement) => {
if (evenement.target.closest('a, .md-tache')) return;
passerEnEdition();
});
apercu.addEventListener('change', (evenement) => {
const coche = evenement.target;
if (!coche.matches('input[type="checkbox"]')) return;
// Seule écriture qui ne passe pas par `remplacer` : le champ est masqué en
// mode aperçu, il ne peut donc pas prendre le focus dont `execCommand` a
// besoin. Cocher une case vide la pile d'annulation du champ — sans grande
// conséquence, puisqu'on n'était pas en train d'y taper.
champ.value = basculerCase(champ.value, Number(coche.dataset.ligne));
// On repeint : la bascule a pu ne rien changer — note modifiée ailleurs
// entre le rendu et le clic —, et la case doit alors revenir à l'état que
// le texte décrit vraiment, pas à celui que le navigateur vient de cocher.
peindreApercu();
signaler();
});
outils.addEventListener('mousedown', (evenement) => {
// Sans cela, le bouton prendrait le focus et la sélection du textarea
// s'évanouirait avant que l'action ne s'applique.
if (evenement.target.closest('button')) evenement.preventDefault();
});
outils.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('button');
if (!bouton) return;
appliquerOutil(champ, OUTILS.find((outil) => outil.cle === bouton.dataset.outil));
signaler();
});
for (const outil of OUTILS) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.dataset.outil = outil.cle;
bouton.textContent = outil.libelle;
bouton.title = outil.titre;
bouton.setAttribute('aria-label', outil.titre);
outils.append(bouton);
}
champ.addEventListener('keydown', (evenement) => {
if (!evenement.ctrlKey && !evenement.metaKey) return;
const cle = evenement.key.toLowerCase() === 'b' ? 'gras'
: evenement.key.toLowerCase() === 'i' ? 'italique'
: null;
if (!cle) return;
evenement.preventDefault();
appliquerOutil(champ, OUTILS.find((outil) => outil.cle === cle));
signaler();
});
afficherMode();
peindreApercu();
return {
champ,
/**
* Charge une note.
*
* @param {object} options
* - reinitialiserMode : choisit le mode d'après le contenu. Vrai à
* l'ouverture du panneau, faux quand la note est rechargée depuis
* ailleurs — refermer l'aperçu sur les doigts de qui tape serait le
* pire moment pour changer de mode.
*/
definir(texte, { reinitialiserMode = false } = {}) {
// Réécrire la valeur d'un textarea y replace le curseur à la fin : on
// s'en abstient quand rien n'a changé, ce qui est le cas le plus fréquent
// lors d'un rafraîchissement venu de la frise.
if (champ.value !== (texte ?? '')) champ.value = texte ?? '';
if (reinitialiserMode) enEdition = !champ.value.trim();
if (!enEdition) peindreApercu();
afficherMode();
},
/** Repeint l'aperçu après une frappe, quand il est visible. */
synchroniser() {
if (!enEdition) peindreApercu();
},
};
}
/**
* Applique une action de la barre d'outils à la sélection courante.
*
* Trois formes, selon l'outil : entourer la sélection, préfixer les lignes
* qu'elle touche, ou insérer un lien. Dans les trois cas la sélection reste
* cohérente après coup — on continue de taper là où on en était.
*/
function appliquerOutil(champ, outil) {
if (!outil) return;
if (outil.entoure) return entourer(champ, outil.entoure);
if (outil.prefixe) return prefixer(champ, outil.prefixe);
if (outil.lien) return insererLien(champ);
}
/**
* Remplace une plage du champ **sans rompre la pile d'annulation** du
* navigateur.
*
* Affecter `champ.value` vide cette pile : `Ctrl+Z` cesse alors de remonter au
* delà de l'action, 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.
*
* `execCommand` n'a pas de remplaçant pour cet usage. Il est marqué obsolète et
* reste pourtant la seule façon d'écrire dans un `textarea` en laissant le
* navigateur enregistrer l'opération comme si elle avait été tapée. Les API qui
* devaient lui succéder (`InputEvent` synthétiques) ne modifient rien : le
* navigateur ignore un événement qu'il n'a pas produit lui-même. Écrire notre
* propre historique reviendrait à réimplémenter `Ctrl+Z`, `Ctrl+Y` et leur
* fusion des frappes voisines — pour un champ de notes, la dépense est sans
* commune mesure avec le service.
*
* D'où le repli sur l'affectation directe si la commande échoue : on perd
* l'annulation, jamais la saisie.
*
* Le curseur est laissé à la fin de l'insertion ; c'est à l'appelant de le
* replacer, ce qui ne touche pas à l'historique.
*/
function remplacer(champ, debut, fin, texte) {
champ.focus();
champ.setSelectionRange(debut, fin);
// `execCommand` écrit là où est le focus, pas dans l'élément qu'on lui
// désigne — il n'en prend aucun. Si le champ n'a pas pu le prendre (il est
// masqué en mode aperçu), la commande irait écrire dans le champ « Nom » ou
// dans le dernier élément actif. On vérifie donc, plutôt que d'insérer du
// markdown au hasard dans le panneau.
if (document.activeElement === champ) {
// Une insertion vide n'en est pas une : c'est une suppression, et
// `insertText` ne la traite pas partout.
const commande = texte ? 'insertText' : 'delete';
try {
if (document.execCommand(commande, false, texte)) return;
} catch {
// Commande refusée : on retombe sur l'affectation ci-dessous.
}
}
const valeur = champ.value;
champ.value = valeur.slice(0, debut) + texte + valeur.slice(fin);
champ.setSelectionRange(debut + texte.length, debut + texte.length);
}
function entourer(champ, marque) {
const { selectionStart: debut, selectionEnd: fin, value: texte } = champ;
const selection = texte.slice(debut, fin);
// Sélection déjà entourée : le bouton retire la marque. C'est ce qu'on attend
// d'un bouton à deux états, et sans cela « gras » deux fois de suite produit
// un `****` qui n'a plus rien de gras.
const dedans =
texte.slice(debut - marque.length, debut) === marque &&
texte.slice(fin, fin + marque.length) === marque;
if (dedans) {
remplacer(champ, debut - marque.length, fin + marque.length, selection);
champ.setSelectionRange(debut - marque.length, fin - marque.length);
} else {
remplacer(champ, debut, fin, marque + selection + marque);
// Sans sélection, le curseur se pose entre les deux marques : le bouton
// arme la mise en forme et on tape dedans.
champ.setSelectionRange(debut + marque.length, fin + marque.length);
}
}
/** Pose (ou retire) un préfixe en tête de chaque ligne touchée par la sélection. */
function prefixer(champ, prefixe) {
const texte = champ.value;
const debutLigne = texte.lastIndexOf('\n', champ.selectionStart - 1) + 1;
const finBrute = texte.indexOf('\n', champ.selectionEnd);
const finLigne = finBrute < 0 ? texte.length : finBrute;
const lignes = texte.slice(debutLigne, finLigne).split('\n');
const toutesPrefixees = lignes.every((ligne) => ligne.startsWith(prefixe));
const modifiees = lignes.map((ligne) =>
toutesPrefixees ? ligne.slice(prefixe.length) : prefixe + ligne
);
const remplacement = modifiees.join('\n');
remplacer(champ, debutLigne, finLigne, remplacement);
champ.setSelectionRange(debutLigne, debutLigne + remplacement.length);
}
/**
* Insère `[libellé](url)` et présélectionne la partie à remplir : l'adresse si
* un libellé était sélectionné, le libellé sinon. On tape aussitôt, sans avoir
* à viser au curseur entre deux parenthèses.
*/
function insererLien(champ) {
const { selectionStart: debut, selectionEnd: fin, value: texte } = champ;
const selection = texte.slice(debut, fin);
const libelle = selection || 'texte';
const insertion = `[${libelle}](https://)`;
remplacer(champ, debut, fin, insertion);
if (selection) {
const debutUrl = debut + libelle.length + 3;
champ.setSelectionRange(debutUrl + 'https://'.length, debutUrl + 'https://'.length);
} else {
champ.setSelectionRange(debut + 1, debut + 1 + libelle.length);
}
}

View File

@@ -10,6 +10,8 @@
* docs/decisions.md, section 21.
*/
import { creerBlocNotes } from './notes.js';
export function creerPanneauProjet(refs, outils, rappels) {
const {
panneau,
@@ -19,11 +21,14 @@ export function creerPanneauProjet(refs, outils, rappels) {
conteneurCouleurs,
suggestions,
blocSuggestions,
notes,
supprimer,
fermer,
} = refs;
const { couleurs, normaliserTags, cleTag, teinteTag } = outils;
const blocNotes = creerBlocNotes(notes, { libelle: 'Notes du projet' });
let idCourant = null;
let projetCourant = null;
@@ -105,6 +110,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
formulaire.tags.value = projet.tags.join(', ');
blocNotes.definir(projet.notes, { reinitialiserMode: true });
peindrePalette(projet.color);
peindreSuggestions();
afficherErreur(null);
@@ -132,6 +138,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
...projetCourant,
name: nom,
tags: tagsSaisis(),
notes: formulaire.notes.value,
...forces,
};
@@ -144,6 +151,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
memesTags
) {
return;
@@ -211,6 +219,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
const actif = document.activeElement;
if (formulaire.name !== actif) formulaire.name.value = projet.name;
if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', ');
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
peindrePalette(projet.color);
peindreSuggestions();
},

View File

@@ -29,6 +29,7 @@ import {
semaineISO,
versUTC,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { pastillesTags } from './tags.js';
/**
@@ -321,6 +322,10 @@ function libelleProjet(projet, tagsActifs = []) {
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
// elles se lisent — un projet n'a pas de barre à lui.
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
// Le grisé de la ligne dit qu'il se passe quelque chose ; encore faut-il
// pouvoir apprendre quoi, et comment revenir en arrière.
(projet.hidden ? '\n\nMasqué de la frise — voir le menu ⋯ pour le rétablir.' : '') +
@@ -439,7 +444,13 @@ function construirePhase(projet, phase, echelle, selection) {
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
losange.dataset.role = 'barre';
losange.title = `${phase.name}${formaterDateLongue(phase.start)}`;
// Un jalon porte des notes comme une tâche — souvent les plus utiles, un
// jalon étant justement le point où l'on consigne une décision. Son
// infobulle les taisait, alors que celle d'une barre les montrait.
losange.title =
`${phase.name}${formaterDateLongue(phase.start)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
elements.push(losange);
const nom = document.createElement('span');
@@ -461,7 +472,11 @@ function construirePhase(projet, phase, echelle, selection) {
barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '');
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
// source markdown rendrait la note moins lisible qu'avant qu'on ne
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
// portent du sens — une puce pour un tiret, une case dessinée pour `[x]`.
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
// Le nom vit dans son propre élément plutôt que directement dans la barre :
// il est `sticky`, ce qui le maintient visible quand la barre s'enfonce sous

View File

@@ -63,7 +63,11 @@ const intervalleBackup = 24 * time.Hour
const portInitial = 8000
const portsTentes = 10
var donneesInitiales = []byte(`{"version": 3, "projects": []}`)
// Doit suivre VERSION_FORMAT dans js/model.js : le fichier créé au premier
// lancement est immédiatement relu par le front, qui refuse une version plus
// récente que la sienne — mais accepterait sans rien dire une version plus
// ancienne, et la réécrirait au format courant.
var donneesInitiales = []byte(`{"version": 4, "projects": []}`)
type serveur struct {
fichierDonnees string

View File

@@ -73,9 +73,9 @@ func TestGetSansFichierRenvoiePlanningVide(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
// Le format courant est la version 3 (js/model.js, VERSION_FORMAT).
if planning.Version != 3 {
t.Errorf("version = %d, attendu 3", planning.Version)
// Le format courant est la version 4 (js/model.js, VERSION_FORMAT).
if planning.Version != 4 {
t.Errorf("version = %d, attendu 4", planning.Version)
}
if len(planning.Projects) != 0 {
t.Errorf("projects = %v, attendu vide", planning.Projects)

View File

@@ -291,6 +291,28 @@ describe('validerPlanning', () => {
);
});
test('donne des notes vides à un projet qui n en a pas', () => {
// Un fichier en version 3 n'en portait pas : il s'ouvre sans rien demander,
// comme un fichier d'avant les tags s'ouvrait avec une liste de tags vide.
const resultat = validerPlanning({ version: 3, projects: [projet()] });
assert.equal(resultat.projects[0].notes, '');
});
test('conserve les notes d un projet', () => {
const resultat = validerPlanning({
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
});
assert.equal(resultat.projects[0].notes, '## Contexte\n\n- [ ] relancer');
});
test('ramène à vide des notes de projet mal typées', () => {
// Cosmétique comme le statut d'une phase : le champ n'engage aucun calcul,
// et bloquer le chargement d'un planning entier pour lui serait
// disproportionné.
const resultat = validerPlanning({ projects: [projet({ notes: 42 })] });
assert.equal(resultat.projects[0].notes, '');
});
test('ramène un statut inconnu à « à venir » sans bloquer le chargement', () => {
const resultat = validerPlanning({
projects: [projet({ phases: [phase({ status: 'n-importe-quoi' })] })],
@@ -697,6 +719,7 @@ describe('modifications', () => {
assert.equal(premier.id, 'site-web');
assert.equal(second.id, 'site-web-2');
assert.notEqual(premier.color, second.color);
assert.equal(premier.notes, '');
});
test('remplacerPhase retrie quand un déplacement change l ordre', () => {