Compare commits

..

7 Commits

Author SHA1 Message Date
815e144d9a Rendre lisibles le statut et la date imposée
All checks were successful
Build / build (push) Successful in 1m2s
Statut : quatre boutons peints de leur texture, si bien que le panneau
sert enfin de légende. « En cours » se raye et « terminé » redevient vif
— la trame les distingue, la teinte n'a plus à le faire.

Date imposée : un cadre tireté cerne la forme entière. Les taquets
tombaient sous les poignées de redimensionnement et disparaissaient sur
une barre courte. Son libellé passe dans le modèle, ce qui la fait
apparaître dans la vue par mois, qui la taisait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:17:15 +02:00
4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:07:31 +02:00
55128fd0d4 Feat(ci): init CI
All checks were successful
Build / build (push) Successful in 3m36s
2026-08-09 21:20:46 +02:00
d6f3b2961b Décrire le cycle de vie dans le README, et l'illustrer
Le jeu de démonstration reçoit trois projets sans phase — un envisagé,
un dont l'horizon est passé, un écarté — sans quoi la fonctionnalité
reste invisible à qui découvre l'outil.

Corrige aussi « node --test tests/ », que Node 26 refuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:58:36 +02:00
60a91ba5b2 Dessiner l'horizon sur la frise, l'éditer et le glisser
La barre d'un projet envisagé est pâle et hachurée, aux bords fondus.
Elle se glisse et se redimensionne comme une phase, mais accrochée au
mois : le geste existe, il ne permet pas de poser une date qu'on n'a
pas. Le panneau ajoute deux champs de mois et trois crans.

Une barre « Afficher » filtre par état, disjonctive là où celle des
tags est conjonctive — d'où des boutons carrés et non des pastilles.
Les écartés en sont décochés au départ.

Engager démarre au premier jour de l'horizon, et clore un projet
demande confirmation quand il reste des phases à écraser.

Au passage : releasePointerCapture peut lever quand le pointeur a
disparu, ce qui sautait l'application du geste. Le nettoyage en est
maintenant isolé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:58:05 +02:00
8404f12656 Porter le cycle de vie et l'horizon dans le modèle, en version 5
L'état n'est pas un champ : il se lit dans horizon, completedDate,
discardedDate et la présence de phases. Les phases commandent, sauf
quand on a prononcé quelque chose.

L'emprise d'un projet retombe sur son horizon faute de phases, et une
saisie n'est jamais réécrite par un calcul : un projet qui perd sa
dernière phase retrouve l'horizon qu'il avait déclaré.

Clore un projet termine aussi toutes ses phases, seule opération du
cycle qui écrase des données. phasesParMois devient entreesParMois :
elle émet aussi les projets sans phase, à leur horizon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:57:34 +02:00
3c7cff3ac6 Décider d'un cycle de vie pour les projets, et d'un horizon
Une petite mairie a plus de projets imaginables que de temps pour les
mener, et la crainte première est d'en oublier. La section 27 pose les
quatre états — envisagé, engagé, terminé, écarté —, l'horizon en mois
des envisagés, et surtout ce qu'on écarte : la matrice d'Eisenhower, un
backlog séparé, une vue de rétrospective.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:56:14 +02:00
18 changed files with 3728 additions and 215 deletions

View File

@@ -0,0 +1,55 @@
# Compile les binaires des cinq plateformes à chaque poussée, et les dépose en
# artefacts téléchargeables depuis la page de l'exécution.
#
# Le dépôt n'ayant aucune dépendance, la pipeline se contente de reprendre les
# commandes de la machine de développement : node --test, puis ./build.sh, qui
# lance lui-même go test avant de compiler.
#
# Gitea lit les workflows dans .gitea/workflows/ — .github/workflows/ marche
# aussi, mais autant nommer les choses par leur maison.
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
# Permet de relancer la compilation à la main depuis l'onglet Actions.
workflow_dispatch:
jobs:
build:
# Label déclaré par act_runner à son enregistrement. S'il a été enregistré
# avec d'autres labels, remplacer ici par celui qui existe :
# ./act_runner --config config.yaml daemon (les labels sont affichés au démarrage)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Installer Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
# Le cache des modules s'appuie sur un go.sum, que ce dépôt n'a pas
# faute de dépendance : activé, il ferait échouer l'étape.
cache: false
- name: Tester le modèle métier
# Node est fourni par l'image du runner ; pas de setup-node, qui
# téléchargerait une distribution pour rien.
run: node --test
- name: Compiler les cinq plateformes
# build.sh lance go test ./... avant de compiler : le serveur est donc
# testé ici aussi.
run: ./build.sh
- name: Déposer les binaires
# v3 et non v4 : l'API d'artefacts de la v4 réclame une Gitea récente,
# là où la v3 fonctionne depuis 1.20.
uses: actions/upload-artifact@v3
with:
name: frise-binaires
path: dist/frise-*
retention-days: 30

185
README.md
View File

@@ -51,7 +51,12 @@ saisi. Pour découvrir l'outil sur des données réalistes, copier `data/exemple
Il faut [Go](https://go.dev) 1.22 ou plus récent, et rien d'autre : aucune dépendance externe,
aucun `vendor/`. Les binaires des trois systèmes se produisent depuis une seule machine, sans
intégration continue ni SDK tiers.
SDK tiers.
Une pipeline Gitea rejoue les mêmes commandes à chaque poussée sur `main``node --test`, puis
`./build.sh` — et dépose les cinq binaires en artefacts de l'exécution, à récupérer depuis
l'onglet **Actions**. Elle ne remplace pas la compilation locale, elle la vérifie : rien
n'oblige à passer par elle pour produire un exécutable.
### Développer
@@ -65,7 +70,7 @@ alors `data/projets.json`, comme avant.
```sh
go test ./... # le serveur
node --test tests/ # le modèle métier
node --test # le modèle métier
```
## Prise en main
@@ -75,17 +80,33 @@ ce nom présélectionné : on tape simplement par-dessus. Il n'y a aucun formula
ici, ni pour une phase. Tout s'applique au fil de la saisie, et un projet créé par erreur se
supprime depuis son propre panneau.
Il naît **envisagé** : sans phase, avec pour seul ancrage un horizon d'un mois, celui où on le
crée. On le repousse à sa place, et on l'engage le jour où on lui donne des dates fermes — voir
[le cycle de vie](#le-cycle-de-vie-dun-projet) juste en dessous.
Dans la colonne de gauche, le **triangle** plie et déplie le couloir d'un projet, et un clic sur
son **nom** ouvre ses paramètres : nom, couleur, tags. Deux commandes apparaissent au survol de
la ligne, sur son bord droit :
son **nom** ouvre ses paramètres : nom, couleur, tags, horizon. Deux commandes apparaissent au
survol de la ligne, sur son bord droit :
- **`+`** ajoute une phase à la suite de la dernière, et ouvre son panneau de détail ;
- **`⋯`** ouvre le menu du projet : ajouter une phase, plier, masquer de la frise, renommer,
figer la référence, supprimer. Un clic droit sur le libellé ouvre le même menu.
déclarer terminé, écarter, figer la référence, supprimer. Un clic droit sur le libellé ouvre le
même menu.
Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens
à tout moment.
Il n'y a pas de commande séparée pour créer un **jalon** ni pour marquer une **date imposée** : ce
sont deux cases du panneau de la phase, indépendantes l'une de l'autre comme du statut. Cocher
« Jalon » ramène la fin sur le début, cocher « Date imposée » encadre les deux champs de date d'un
trait tireté — le même qu'on verra sur la barre. Les deux bascules jouent dans les deux sens à tout
moment.
Le **statut** se choisit sur quatre boutons, chacun peint de la texture qu'il donne à la barre. Deux
traits suffisent à les lire : c'est **plein** quand rien ne bouge et **rayé** quand quelque chose se
passe ; c'est la **couleur pleine** du projet dès qu'une phase a commencé, et une teinte **diluée**
tant qu'elle n'a pas commencé. D'où : à venir pâle et bordé, en cours plein et rayé, terminé plein et
nu, bloqué rayé de rouge — seule teinte étrangère, parce que c'est la seule chose qui ne parle pas
d'avancement. La couleur reste celle du projet : elle distingue les couloirs, jamais l'avancement. Le
panneau sert ainsi de légende — il n'y en a pas d'autre, et il n'y a rien à apprendre avant de s'en
servir.
La colonne se **redimensionne** en glissant la ligne qui la sépare de la frise, en haut à
gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur,
@@ -96,13 +117,15 @@ Une fois un projet en place :
| Geste | Effet |
|---|---|
| Glisser le corps d'une barre | Décale la phase, durée conservée |
| Glisser la barre résumé d'un projet | Repousse le projet entier, écarts conservés |
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
| Glisser le bord d'une barre | Change la date de début ou de fin |
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase |
| Clic sur un nom de phase hors écran | Ramène la frise sur elle |
| `←` `→` | Décale d'un jour la phase du curseur |
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
| `←` `→` | Décale d'un jour la phase du curseur — ou tout le projet, sur sa ligne |
| `Maj` + `←` `→` | Allonge ou raccourcit la phase d'un jour |
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
| Clic dans une zone vide | Ferme le panneau ouvert |
| `Maj` + molette | Fait défiler la frise horizontalement |
@@ -111,6 +134,94 @@ 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.
### Repousser un projet en bloc
Un projet engagé glisse dans le temps — c'est même ce qui arrive le plus souvent. Plutôt que de
reprendre ses phases une à une, on **glisse sa barre résumé** : le même nombre de jours s'applique à
toutes, jalons compris, et les écarts entre elles sont conservés au jour près. Le projet est
repoussé, il n'est pas replanifié. Les flèches `←` `→` font la même chose au jour près, curseur posé
sur la ligne du projet.
**Ce qui est terminé ne bouge pas.** Les phases finies restent où elles sont : décaler un projet,
c'est reconnaître qu'il prendra plus de temps, pas réécrire ce qu'on a déjà vécu. Sur un projet à
moitié fait, la barre s'étire donc au lieu de se déplacer — son bord gauche tient et le reste s'en
va. Un projet dont tout est terminé n'offre aucune prise, et son infobulle le dit.
**Ce dont on ne décide pas non plus.** Une phase peut porter une **date imposée** : elle est
déléguée à quelqu'un d'autre, ou tenue par un contrat, un créneau, une échéance réglementaire. La
case se coche dans son panneau, sous « Jalon », et sa forme se cerne alors d'un **trait tireté**
une barre comme un losange, et jusqu'aux deux champs de date du panneau. À ne pas confondre avec le
contour plein de la phase sélectionnée : celui-ci est dedans et discontinu, celui-là dehors et
franc. Repousser le projet ne la repousse pas : si tout le reste
glisse, c'est le créneau qu'on rate, et le chevauchement qui apparaît est justement ce qu'on voulait
voir. Elle reste glissable seule, elle : « imposée » veut dire *indépendante du projet*, pas
*immuable*.
À ne pas confondre avec le statut **bloqué**, son voisin dans le panneau : bloqué dit que ça
n'avance pas, date imposée que l'échéance ne vous appartient pas. Une phase peut être les deux.
Le **planning de référence ne suit pas** : repousser de trois semaines affiche aussitôt une dérive
de trois semaines. C'est voulu — le geste inscrit ce qu'il coûte.
### Le cycle de vie d'un projet
On a presque toujours plus de projets imaginables que de temps pour les mener, et l'enjeu n'est pas
de les oublier en attendant. Un projet traverse donc quatre états, dont **deux se déduisent tout
seuls** — il n'y a rien à tenir à jour :
| État | Comment on le sait |
|---|---|
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
| **Engagé** | on lui a donné au moins une phase datée |
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
| **Écarté** | on a décidé de ne pas le faire |
Le flux tient en une phrase : **une idée entre avec un horizon, on la situe, on l'engage en lui
donnant des dates, elle vit sur la frise, elle s'achève ou on l'écarte.**
Engager n'est donc pas un bouton, c'est un geste : ajouter la première phase. Terminer et écarter,
eux, s'actent — livré n'est pas clos, et abandonner est une décision. Leurs deux commandes sont au
bas du **panneau du projet**, sous le champ qui dit où il en est, et aussi dans son menu **`⋯`**.
**Déclarer un projet terminé termine aussi toutes ses phases**, jalons compris : sans quoi la frise
se contredirait, avec des tâches « à venir » dans un projet fini. C'est la seule opération du cycle
qui écrase quelque chose — le statut d'une phase bloquée est perdu, et rouvrir le projet ne le
rendra pas. Elle demande donc confirmation, mais seulement s'il reste des phases à changer.
#### L'horizon
Un projet envisagé n'est jamais hors du temps : il porte un **horizon en mois** — « mars 2027 »,
« de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à cet horizon.
C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et que six
barres pâles s'y superposent, on décide devant l'évidence.
Sa barre est hachurée et ses bords sont fondus, parce que ces dates ne sont pas fermes. Elle se
glisse et se redimensionne comme une barre de phase, mais **accrochée au mois** là où celle d'une
phase l'est au lundi : le geste est possible, il ne permet simplement pas de poser une date qu'on
n'a pas. Le panneau du projet offre en plus trois crans — `3 mois`, `+3 mois`, `+1 an` — et les
deux mois au clavier.
La largeur de la barre *est* l'incertitude, et elle se resserre à mesure que le projet mûrit :
d'abord une plage large, puis un mois, puis les dates fermes de l'engagement.
**Engager démarre à l'horizon** : la première phase d'un projet envisagé naît au premier jour de son
horizon, là où la barre pâle commençait. On précise ensuite ses dates comme n'importe quelle phase.
Un projet dont l'**horizon est passé** — ou qui n'en a jamais eu — le dit sur sa ligne, par une
pastille **« à dater »**. C'est ce qui empêche la liste des envisagés de devenir une décharge : on
repousse, ou on écarte, mais on ne laisse rien dormir.
#### Écarter n'est pas supprimer
Un projet écarté quitte la frise, mais il garde tout — notes comprises, où l'on aura écrit
pourquoi on a dit non. Les mêmes idées reviennent tous les deux ans, portées par d'autres, et
savoir qu'on les a déjà examinées vaut mieux que de refaire le débat.
On le retrouve en cochant **Écartés** dans la barre *Afficher*, et **Remettre en jeu** l'en sort. Il
n'y a alors aucune destination à choisir : le projet retrouve seul l'état que ses données
commandent — engagé s'il a des phases, envisagé sinon. Son horizon étant resté celui d'avant, il
réclamera aussitôt une date, et c'est voulu.
### Au clavier, à la vim
La frise a un **curseur** : la ligne sur laquelle portent les raccourcis. Il se pose en cliquant une
@@ -188,11 +299,21 @@ liste, projets mêlés, groupés par mois — celui où chaque phase **commence*
« qu'est-ce qui arrive le mois prochain », que la frise donne mal parce qu'elle sépare les projets
en couloirs.
Les **projets sans phase** y figurent aussi, au premier mois de leur horizon : un projet visé pour
mars 2027 arrive bel et bien en mars 2027, et la liste répond à « qu'est-ce qui arrive », pas à
« qu'est-ce qui est déjà décidé ». Leur ligne occupe les mêmes colonnes, en italique, et porte
« Envisagé » là où les autres portent leur statut. Un projet **écarté** avant d'avoir jamais été
engagé s'y retrouve de la même façon — barré, et seulement si l'on a coché *Écartés*.
Chaque ligne se lit dans cet ordre : quand — la période, `31 → 13 nov.` quand la phase déborde du
mois —, chez qui — le projet et ses tags —, puis quoi — l'intitulé de la tâche ou du jalon — et
enfin son statut. Une phase longue n'est **pas** répétée dans les mois qu'elle traverse : elle
n'apparaît qu'à son mois de départ, avec sa date de fin écrite en clair.
La petite marque en tête de ligne reprend les formes de la frise — barre pour une tâche, losange pour
un jalon, texture du statut, cadre tireté pour une **date imposée**. C'est ici qu'elle compte le
plus : une liste d'échéances doit dire lesquelles ne vous appartiennent pas.
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. Elle s'y affiche rendue, comme dans le panneau. Rien n'est affiché
@@ -224,16 +345,35 @@ portant les deux. Le filtre n'est pas enregistré, il ne vaut que pour la sessio
Les barres s'accrochent au lundi pendant le glisser. Pour une date au jour près, passer
par les champs du panneau de détail.
### Afficher par état
Une seconde barre, **Afficher**, paraît au-dessus dès que le planning compte plus d'un état. Elle
montre chacun avec son compte — *Envisagés (4)*, *Écartés (7)* — et un clic l'affiche ou le masque.
Les écartés en sont décochés au départ : le cimetière ne s'ouvre que si on le demande.
Elle ne joue **pas** dans le même sens que le filtre par tags, et c'est pourquoi elle ne lui
ressemble pas : un projet porte plusieurs tags mais un seul état, si bien que cocher un tag de plus
resserre la sélection quand cocher un état de plus l'élargit. D'où « Filtrer » d'un côté, « Afficher »
de l'autre, et des boutons carrés plutôt que des pastilles. Comme le filtre par tags, celui-ci vaut
pour les deux vues et n'est pas enregistré.
## 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. 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), et
porter une **date imposée** quand elle est déléguée ou tenue du dehors — le décalage du projet ne
l'emporte alors pas.
- **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des
données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et
ne s'y perd pas.
- **Un horizon en mois** pour les projets envisagés, rendu en barre hachurée aux bords fondus : la
largeur dit l'incertitude, et un horizon dépassé réclame de lui-même qu'on tranche.
- **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
glisse pas.
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle se
glisse pour repousser le projet entier.
- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il
ne reste que celle-ci.
- **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa
@@ -249,7 +389,7 @@ par les champs du panneau de détail.
- **Une vue par mois** : toutes les tâches et tous les jalons en une liste, projets mêlés, groupés
par mois de début. Elle se lit, elle ne s'édite pas.
- **Des tags sur les projets**, et un filtre qui restreint les deux vues à ceux qui les portent
tous.
tous — doublé d'un affichage par état, qui joue lui en sens inverse.
- **Repère « aujourd'hui »** et masquage d'un projet.
## Ce que l'outil ne fait pas
@@ -257,6 +397,11 @@ par les champs du panneau de détail.
Volontairement : pas de dépendances entre phases, pas de sous-tâches, pas de collaboration temps
réel, pas d'authentification, pas de gestion de charge par personne, pas de notifications.
**Pas de note de priorité non plus, ni de matrice.** L'arbitrage passe par l'horizon : dire « celui-ci
au printemps, celui-là on verra en 2028 » *est* la décision, et un score posé à côté des dates se
périmerait en silence. Ce qui manque encore, c'est le départage fin entre deux projets visant le même
trimestre — il attend d'avoir un endroit où ranger, la frise sachant situer mais pas ordonner.
Les durées se comptent en **jours calendaires** : ni week-ends ni jours fériés ne sont gérés. À
l'échelle de phases de plusieurs semaines, la distinction n'apporte rien.
@@ -308,8 +453,9 @@ node --test # la logique métier
go test ./... # le serveur
```
Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des
identifiants, normalisation et filtrage des tags, regroupement des phases par mois. Côté serveur : lecture et écriture du planning,
Côté navigateur : validation des dates et des horizons, bornes et emprise d'un projet, calcul de
dérive, génération des identifiants, normalisation et filtrage des tags, regroupement des phases par
mois, dérivation de l'état d'un projet et transitions de son cycle de vie. Côté serveur : lecture et écriture du planning,
refus des corps invalides, rotation des sauvegardes, écriture atomique, résolution du chemin de
données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node et à Go suffisent.
@@ -319,12 +465,13 @@ données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node
|---|---|
| `main.go` | Sert l'interface embarquée, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes |
| `build.sh` | Compile les binaires des cinq plateformes dans `dist/` |
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags, regroupement par mois |
| `.gitea/workflows/build.yml` | Pipeline Gitea : tests puis `build.sh`, binaires déposés en artefacts |
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, cycle de vie et horizon, référence, dérive, tags, regroupement par mois |
| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état |
| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli, curseur |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes, ancrage sur le mois courant |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes de phase et de projet envisagé, 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/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase ou un projet, au mois pour un horizon |
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
| `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 |

View File

@@ -21,6 +21,10 @@
--accent: #3b82f6;
--danger: #dc2626;
--aujourdhui: #ef4444;
/* Réservé à ce qui réclame un arbitrage sans être une erreur : un horizon
dépassé n'est pas une faute, c'est une décision à reprendre. D'où une teinte
distincte de `--danger`, qui signale ce qui ne va pas. */
--attention: #b45309;
--hauteur-barre: 26px;
/* Barre cumulative d'un projet, et fantôme qui la double. Ils sont frères,
@@ -53,6 +57,7 @@
--texte: #f1f5f9;
--texte-doux: #94a3b8;
--ombre: 0 1px 2px rgb(0 0 0 / 0.3);
--attention: #fbbf24;
}
}
@@ -209,6 +214,68 @@ button[aria-pressed="true"] {
white-space: nowrap;
}
/* --- filtre par état ------------------------------------------------------ */
/* Même bandeau que le filtre par tags, mais des boutons carrés et non des
pastilles rondes : les deux filtres jouent en sens contraire — cocher un tag
resserre, cocher un état élargit — et rien ne serait plus trompeur que deux
commandes identiques aux effets opposés. */
.etats {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 16px;
background: var(--surface);
border-bottom: 1px solid var(--bordure);
flex-shrink: 0;
}
.etats__liste {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.etat-filtre {
font-size: 12px;
padding: 3px 10px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--surface);
color: var(--texte-doux);
cursor: pointer;
white-space: nowrap;
}
/* Un état affiché est plein, un état masqué est creux. La différence se lit au
remplissage et pas seulement à la teinte : la barre en porte quatre côte à
côte, et la couleur seule n'y suffirait pas. */
.etat-filtre[aria-pressed="true"] {
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
.etat-filtre:hover:not(:disabled) {
border-color: var(--accent);
}
/* Le dernier état affiché ne se décoche pas : tout masquer reviendrait à vider
la frise sans qu'aucun geste ne l'ait demandé. */
.etat-filtre:disabled {
cursor: default;
opacity: 0.75;
}
/* Les écartés se distinguent des trois états vivants, cochés ou non : c'est le
seul dont l'ouverture change la nature de ce qu'on regarde. */
.etat-filtre[data-etat="discarded"][aria-pressed="true"] {
background: color-mix(in srgb, var(--attention) 12%, var(--surface));
border-color: var(--attention);
color: var(--attention);
}
/* --- tags ----------------------------------------------------------------- */
/* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du
@@ -539,6 +606,48 @@ button[aria-pressed="true"] {
color: #16a34a;
}
/* Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu. Même
forme que la dérive, dont c'est le pendant : l'une dit qu'un projet engagé a
glissé, l'autre qu'un projet envisagé attend qu'on tranche.
Volontairement sobre : ce signal doit rester lisible sur une colonne qui en
porte plusieurs, sans devenir l'alerte qu'on apprend à ignorer. */
.libelle-projet__adater {
flex: 0 0 auto;
font-size: 11px;
font-weight: 500;
padding: 1px 5px;
border-radius: 10px;
white-space: nowrap;
background: color-mix(in srgb, var(--attention) 15%, transparent);
color: var(--attention);
cursor: default;
}
/* --- états du cycle de vie ------------------------------------------------ */
/* Un projet terminé **reste** sur la frise : elle est notre mémoire, et l'en
retirer perdrait le bilan (décision 27). Il s'éteint donc au lieu de
disparaître — assez pour que l'œil aille d'abord à ce qui est vivant, pas
assez pour qu'on ne puisse plus le lire.
L'écarté va plus loin : il n'est visible que si on l'a demandé au filtre, et
le gris dit qu'il ne se passera plus rien. */
.libelle-projet[data-etat="completed"],
.couloir--projet[data-etat="completed"] {
opacity: 0.65;
}
.libelle-projet[data-etat="discarded"],
.couloir--projet[data-etat="discarded"] {
opacity: 0.5;
filter: grayscale(0.85);
}
/* Le nom d'un projet écarté se barre : l'opacité seule dirait « en retrait »,
là où il faut lire « on ne le fera pas ». */
.libelle-projet[data-etat="discarded"] .libelle-projet__nom {
text-decoration: line-through;
}
.plier {
border: 0;
background: none;
@@ -695,19 +804,52 @@ button[aria-pressed="true"] {
}
/* Le statut se lit à la texture de la barre, pas à sa couleur : la couleur
appartient au projet, elle sert à distinguer les couloirs entre eux. */
appartient au projet, elle sert à distinguer les couloirs entre eux.
Une seule grammaire pour les quatre, et elle tient en deux traits :
- **plein** pour un état stable, **rayé** pour ce qui bouge ou coince ;
- la couleur du projet **pleine** dès qu'une phase a commencé, **diluée** tant
qu'elle n'a pas commencé.
D'où : à venir dilué et bordé, en cours plein et rayé, terminé plein et nu,
bloqué rayé de rouge — seule teinte étrangère, parce que c'est la seule chose
qui ne parle pas d'avancement.
« Terminé » a longtemps été assourdi, d'abord d'un gris fixe puis d'une
dilution. Les deux disaient « c'est derrière nous » en donnant une frise
morte, où la majorité des barres d'un projet avancé sont éteintes. C'est la
rayure d'« en cours » qui a permis de le rendre vif : la trame distingue
désormais ces deux-là, la teinte n'a plus à s'en charger. */
.barre--todo {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
color: var(--texte);
border: 1px solid var(--couleur-projet);
}
/* Rayée, et non pleine : posée à côté d'une barre terminée — un aplat de la même
teinte —, une couleur pleine ne se distinguait pas assez pour qu'on lise
l'avancement d'un couloir en diagonale. La rayure lui donne ce que les trois
autres textures ont déjà : une trame, et pas seulement une nuance. Elle mêle
la couleur du projet à elle-même diluée, là où celle d'une phase bloquée y
mêle du rouge : le motif est le même, la teinte dit lequel des deux on
regarde. */
.barre--doing {
background: var(--couleur-projet);
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 6px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 12px
);
}
/* La couleur du projet, pleine et nue — c'est la seule des quatre à n'avoir
aucune marque, et c'est bien ce qu'on veut dire : il n'y a plus rien à en
suivre. Déclarée quand même, et pas laissée au fond de `.barre` : ce qui vaut
pour un statut doit se lire à l'endroit où on lit les statuts. */
.barre--done {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
background: var(--couleur-projet);
}
.barre--blocked {
@@ -720,6 +862,42 @@ button[aria-pressed="true"] {
);
}
/* Une phase dont la date ne nous appartient pas — déléguée, contractuelle,
réglementaire (docs/decisions.md, section 28).
La forme est **cerclée**, tout entière : c'est tenu de partout, et le trait
suit le contour quel qu'il soit — une barre, un losange. Deux taquets aux
extrémités disaient la même chose et le disaient mal : sur une barre de deux
jours ils l'avalaient, sur une barre à venir ils se lisaient comme une bordure
épaissie, et ils occupaient la place où le survol révèle les poignées de
redimensionnement — le signe « cette date ne vous appartient pas » tombait
exactement là où l'outil dit « tirez ici pour la changer ».
La marque se cumule avec les quatre textures de statut au lieu de leur
disputer la place : c'est bien une phase à venir, en cours ou bloquée, qui se
trouve en plus tenue par le dehors.
Le trait est **tireté**, et c'est ce qui le sépare du contour de la sélection,
plein. Un cerclage plein avait été essayé d'abord : à la taille réelle des
barres, on ne le distinguait pas d'une phase simplement sélectionnée. Le tiret
dit d'ailleurs ce qu'il faut — ce n'est pas nous qui traçons cette ligne.
Il vit sur un `::after` plutôt que sur la barre : un élément n'a qu'un
`outline`, et c'est la sélection qui le prend. Les deux marques se superposent
donc sans se gêner sur une phase imposée qu'on vient de sélectionner — un
cadre tireté dedans, un contour plein dehors. */
.barre--imposee::after,
.jalon--imposee::after {
content: '';
position: absolute;
inset: 0;
border: 2px dashed var(--texte);
border-radius: inherit;
/* Le losange tourne, son cadre avec lui : le pseudo-élément hérite de la
rotation du jalon, et n'a donc rien à en savoir. */
pointer-events: none;
}
/* Poignées de redimensionnement, révélées au survol de la barre. */
.poignee {
position: absolute;
@@ -731,10 +909,17 @@ button[aria-pressed="true"] {
background: rgb(255 255 255 / 0.5);
}
.barre:hover .poignee {
.barre:hover .poignee,
.barre-horizon:hover .poignee {
opacity: 1;
}
/* Sur fond pâle et hachuré, un voile blanc à demi transparent ne se voit pas :
les poignées d'un horizon prennent la teinte de son projet. */
.barre-horizon .poignee {
background: color-mix(in srgb, var(--couleur-projet) 55%, transparent);
}
.poignee--debut {
left: 0;
border-radius: 4px 0 0 4px;
@@ -760,14 +945,35 @@ button[aria-pressed="true"] {
box-shadow: var(--ombre);
}
/* Mêmes textures que les barres, hachures resserrées : le losange fait 14px de
côté, une rayure de 6px n'y tiendrait pas deux fois. */
.jalon--doing {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px
);
}
.jalon--done {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
background: var(--couleur-projet);
}
.jalon--blocked {
background: var(--danger);
}
/* Rien ici pour `.jalon--imposee` : son cadre tireté est déclaré avec celui des
barres, plus haut, à la même épaisseur. C'est la même marque, et un losange
n'est qu'une autre forme à cerner — son `::after` s'appuie sur le
`position: absolute` que `.jalon` porte déjà, et tourne avec lui.
Le trait avait été affiné à 1px, au motif que la longueur des tirets suit
l'épaisseur et qu'un losange de 14px n'en porte que quelques-uns. Mais c'est
la lisibilité qui tranche : à 1px sur une forme de cette taille, la marque
s'efface. Mieux vaut peu de tirets qu'on voit que beaucoup qu'on devine. */
.jalon--selectionne {
outline: 2px solid var(--texte);
outline-offset: 1px;
@@ -791,8 +997,8 @@ button[aria-pressed="true"] {
déplié, et rendue à l'identique dans les deux cas — c'est le même objet, il
n'a pas à changer d'aspect quand on ouvre le couloir.
Volontairement plus mince qu'une barre de phase : elle voisine avec elles
dès que le projet est déplié, et ne se glisse pas.
Lecture seule : voir docs/decisions.md, section 8. */
dès que le projet est déplié.
Elle se glisse pour repousser le projet : voir docs/decisions.md, section 8. */
.barre-cumulative {
position: absolute;
top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2);
@@ -805,6 +1011,25 @@ button[aria-pressed="true"] {
z-index: 2;
}
/* La prise ne s'annonce que là où elle mène quelque part : sur un projet dont
tout est terminé, la barre n'a pas de rôle et garde le curseur par défaut —
rien ne bougerait. */
.barre-cumulative[data-role='cumul'] {
cursor: grab;
}
.barre-cumulative[data-role='cumul']:hover {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
}
/* Mince comme elle est, une barre cumulative saisie se repère mal. L'ombre la
décolle du couloir le temps du geste, sans changer sa géométrie — ce qui
décalerait ce qu'on est en train de viser. */
.barre-cumulative.barre--glissee {
cursor: grabbing;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--couleur-projet) 60%, transparent);
}
.segment {
position: absolute;
top: 0;
@@ -858,6 +1083,67 @@ button[aria-pressed="true"] {
height: 4px;
}
/* --- horizon d'un projet envisagé ---------------------------------------- */
/* La barre d'un projet qui n'a pas encore de phase. Tout, ici, sert à dire que
les dates ne sont pas fermes (décision 27) :
- les **bords fondus** (`mask-image`), qui suppriment le trait net qu'on
pourrait prendre pour une date de début ou de fin ;
- les **hachures obliques**, qui la distinguent au premier coup d'œil d'une
barre de phase, y compris pour qui ne perçoit pas la nuance de teinte.
Elle se glisse et se redimensionne comme une barre de phase, mais accrochée au
**mois** plutôt qu'au lundi : le geste est possible, il ne permet simplement
pas de poser une date qu'on n'a pas.
Sa largeur porte à elle seule l'incertitude : un mois pour un projet mûr, une
large plage pour un projet vague. */
.barre-horizon {
position: absolute;
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
height: var(--hauteur-barre);
border-radius: 4px;
color: var(--texte-doux);
font-size: 12px;
font-style: italic;
line-height: var(--hauteur-barre);
cursor: grab;
user-select: none;
z-index: 2;
background:
repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--couleur-projet) 26%, transparent),
color-mix(in srgb, var(--couleur-projet) 26%, transparent) 5px,
transparent 5px,
transparent 10px
),
color-mix(in srgb, var(--couleur-projet) 12%, var(--surface));
mask-image: linear-gradient(
90deg,
transparent,
#000 14px,
#000 calc(100% - 14px),
transparent
);
}
/* Un horizon dépassé se teinte, en écho à la pastille « à dater » de la colonne.
La marque est portée deux fois — dans la colonne et sur la frise — parce que
les deux se lisent séparément : on parcourt l'une, on balaie l'autre. */
.barre-horizon--depasse {
background:
repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--attention) 30%, transparent),
color-mix(in srgb, var(--attention) 30%, transparent) 5px,
transparent 5px,
transparent 10px
),
color-mix(in srgb, var(--attention) 12%, var(--surface));
}
/* --- panneau de détail --------------------------------------------------- */
/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant
@@ -944,7 +1230,14 @@ button[aria-pressed="true"] {
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. */
/* `--couleur-projet` se déclare ici, sur l'élément même que detail.js peint en
style inline à l'ouverture : le style inline l'emporte sur cette règle, et
l'accent ne sert que de repli tant qu'aucune phase n'est ouverte — sans quoi
les `color-mix` des textures de statut se peindraient dans le vide. La
déclarer plus bas, sur le groupe de boutons, ferait l'inverse : une valeur
posée sur un descendant gagne toujours contre celle dont il hérite. */
.panneau__corps {
--couleur-projet: var(--accent);
flex: 1;
min-height: 0;
padding: 16px;
@@ -965,7 +1258,6 @@ button[aria-pressed="true"] {
.panneau__corps input[type="text"],
.panneau__corps input[type="date"],
.panneau__corps select,
.panneau__corps textarea {
font: inherit;
padding: 6px 8px;
@@ -986,6 +1278,122 @@ button[aria-pressed="true"] {
gap: 10px;
}
/* Le cerclage de la barre imposée, reporté sur les deux champs que la case
qualifie : cocher « date imposée » n'avait jusque-là aucun effet visible dans
le panneau, alors que la conséquence porte précisément sur ces dates-là.
Le padding est repris par une marge négative de même valeur : les champs ne
bougent pas d'un pixel quand la case change d'état, seul le contour apparaît.
Il déborde de 8px dans les 16px de padding du corps, donc sans jamais sortir
du panneau. */
.champ-double--imposee {
padding: 6px 8px;
margin: -6px -8px;
border-radius: var(--rayon);
outline: 2px dashed var(--texte);
outline-offset: -2px;
}
/* --- statut, en quatre boutons ------------------------------------------- */
/* Chacun peint de la texture qu'il donne à la barre sur la frise : voir
index.html pour le pourquoi. La couleur, elle, vient du formulaire — cf. le
repli déclaré sur `.panneau__corps`. */
.statuts {
margin: 0;
padding: 0;
border: 0;
}
.statuts legend {
padding: 0 0 4px;
font-size: 12px;
font-weight: 500;
color: var(--texte-doux);
}
.statuts__choix {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
/* Le label *est* le bouton. Le radio natif s'efface sans quitter le DOM : c'est
lui qui porte la valeur, réunit le groupe et répond aux flèches. */
.panneau__corps .statut {
flex-direction: row;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
font-weight: 400;
color: var(--texte);
cursor: pointer;
}
.statut input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.statut:hover {
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.statut:has(input:checked) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
font-weight: 600;
}
/* Le focus se pose sur un radio devenu invisible : c'est au bouton de le
montrer, sans quoi la navigation aux flèches se ferait à l'aveugle. */
.statut:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Les quatre textures, reprises telles quelles des barres — hachures resserrées
à l'échelle de la marque, comme celles de la vue par mois. */
.statut__marque {
flex: none;
width: 18px;
height: 9px;
border-radius: 2px;
background: var(--couleur-projet);
}
.statut--todo .statut__marque {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
border: 1px solid var(--couleur-projet);
}
.statut--doing .statut__marque {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px
);
}
.statut--done .statut__marque {
background: var(--couleur-projet);
}
.statut--blocked .statut__marque {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 3px,
color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 6px
);
}
.case {
flex-direction: row !important;
align-items: center;
@@ -1011,6 +1419,106 @@ button[aria-pressed="true"] {
margin-top: 4px;
}
/* Les deux actes du cycle de vie, côte à côte et à parts égales : aucun des deux
n'est plus courant que l'autre, et les mettre l'un sous l'autre suggérerait
une progression qui n'existe pas — on termine *ou* on écarte. */
.panneau__actions--cycle {
display: flex;
gap: 8px;
}
.panneau__actions--cycle button {
flex: 1 1 0;
white-space: nowrap;
}
/* --- cycle de vie dans le panneau ---------------------------------------- */
/* Où en est le projet. Une ligne de texte et non un sélecteur : l'état se
déduit des données (décision 27), il n'y a rien à y choisir. */
.panneau__etat {
margin: 0;
font-size: var(--taille-annotation);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--texte-doux);
}
.panneau__etat[data-etat="discarded"] {
color: var(--attention);
}
.horizon {
border: 1px solid var(--bordure);
border-radius: var(--rayon);
padding: 8px 10px 10px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.horizon > legend {
font-size: var(--taille-annotation);
color: var(--texte-doux);
padding: 0 4px;
}
/* Les deux bornes côte à côte : c'est un intervalle, et le lire sur deux lignes
ferait perdre ce que sa largeur veut dire. */
.horizon__intervalle {
display: flex;
gap: 8px;
}
.horizon__intervalle label {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: var(--taille-annotation);
color: var(--texte-doux);
}
.horizon__crans {
display: flex;
gap: 6px;
}
.horizon__crans button {
flex: 1 1 0;
padding: 4px 6px;
font-size: var(--taille-annotation);
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--surface);
color: var(--texte);
cursor: pointer;
}
.horizon__crans button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.horizon__crans button:disabled {
opacity: 0.45;
cursor: default;
}
.horizon__aide {
margin: 0;
font-size: var(--taille-annotation);
line-height: 1.35;
color: var(--texte-doux);
}
/* Un horizon passé le dit ici comme il le dit sur la frise et dans la colonne :
c'est le même signal, vu depuis les trois endroits où l'on travaille. */
.horizon__aide[data-alerte="true"] {
color: var(--attention);
}
/* --- bloc de notes -------------------------------------------------------- */
/* Le bloc prend toute la hauteur que les champs au-dessus lui laissent. C'est
@@ -1641,8 +2149,18 @@ kbd {
border: 1px solid var(--couleur-projet);
}
.mois-entree--doing .mois-entree__marque {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px
);
}
.mois-entree--done .mois-entree__marque {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
background: var(--couleur-projet);
}
.mois-entree--blocked .mois-entree__marque {
@@ -1667,6 +2185,16 @@ kbd {
background: var(--danger);
}
/* Le même cadre tireté que sur la frise, à l'échelle de la marque : 1px sur huit
de haut plutôt que 2 sur vingt-six. En `border` et non sur un `::after` — la
marque n'a pas de sélection à qui laisser la place, et `border-box` lui garde
sa taille. Déclaré après les quatre textures de statut, dont il ne prend pas
la place : c'est une phase à venir, en cours ou bloquée qui se trouve en plus
tenue par le dehors. */
.mois-entree--imposee .mois-entree__marque {
border: 1px dashed var(--texte);
}
/* Chiffres tabulaires : les quantièmes s'alignent d'une ligne à l'autre, ce qui
fait de la colonne une échelle lisible plutôt qu'une pile de textes. */
.mois-entree__dates {
@@ -1791,6 +2319,48 @@ button.mois-entree__plier {
color: var(--texte-doux);
}
/* --- ligne d'un projet envisagé ------------------------------------------ */
/* Mêmes colonnes qu'une ligne de phase — la liste reste une grille qu'on lit en
diagonale —, mais tout y dit que rien n'est arrêté : l'italique de l'intitulé
et les hachures de la marque, qui reprennent celles de la barre sur la frise.
Ce qu'on a appris à lire d'un côté vaut de l'autre. */
.mois-entree--envisage .mois-entree__nom {
font-style: italic;
color: var(--texte-doux);
}
.mois-entree--envisage .mois-entree__marque {
background: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--couleur-projet) 60%, transparent),
color-mix(in srgb, var(--couleur-projet) 60%, transparent) 3px,
transparent 3px,
transparent 6px
);
border: 1px solid color-mix(in srgb, var(--couleur-projet) 45%, transparent);
}
.mois-entree__statut[data-statut="considered"],
.mois-entree__statut[data-statut="discarded"] {
font-style: italic;
}
/* Un projet écarté qui n'a jamais été engagé passe aussi par cette ligne — il
n'a que son horizon pour se situer. Il s'y lit comme sur la frise : éteint et
barré, pour qu'on ne le confonde pas avec ce qui va arriver. */
.mois-entree--envisage[data-etat="discarded"] {
opacity: 0.55;
}
.mois-entree--envisage[data-etat="discarded"] .mois-entree__projet {
text-decoration: line-through;
}
.mois-entree--envisage[data-etat="discarded"] .mois-entree__statut {
color: var(--attention);
}
.mois__vide {
max-width: 1080px;
margin: 48px auto;

View File

@@ -1,5 +1,5 @@
{
"version": 4,
"version": 6,
"projects": [
{
"id": "site-web",
@@ -99,6 +99,16 @@
"milestone": false,
"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": "fenetre-hebergeur",
"name": "Fenêtre de maintenance",
"start": "2026-09-12",
"end": "2026-09-13",
"status": "todo",
"milestone": false,
"fixed": true,
"notes": "Créneau imposé par l'hébergeur : un seul week-end par trimestre.\n\nRepousser le projet ne la repousse pas — si le reste glisse, c'est ce créneau qu'on rate.\n"
},
{
"id": "bascule-generale",
"name": "Bascule générale",
@@ -156,6 +166,40 @@
"notes": ""
}
]
},
{
"id": "refonte-intranet",
"name": "Refonte de l'intranet",
"color": "#8b5cf6",
"tags": ["interne", "web"],
"collapsed": false,
"hidden": false,
"notes": "Envisagé, pas encore engagé : l'horizon dit qu'on y viendra, pas quand exactement.\n\n- [ ] recenser ce que l'ancien intranet porte encore\n- [ ] estimer le budget\n",
"horizon": { "start": "2027-04", "end": "2027-09" },
"phases": []
},
{
"id": "archivage",
"name": "Archivage des dossiers papier",
"color": "#06b6d4",
"tags": ["interne"],
"collapsed": false,
"hidden": false,
"notes": "Horizon dépassé : à repousser ou à écarter.",
"horizon": { "start": "2026-03", "end": "2026-05" },
"phases": []
},
{
"id": "application-mobile",
"name": "Application mobile",
"color": "#ec4899",
"tags": ["client"],
"collapsed": false,
"hidden": false,
"notes": "Écartée : le site refondu sera responsive, une application native ne se justifie pas.\n\nÀ reprendre si un besoin hors ligne apparaît.",
"horizon": { "start": "2027-01", "end": "2027-06" },
"discardedDate": "2026-06-22",
"phases": []
}
]
}

View File

@@ -102,15 +102,62 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel
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
## 8. La barre cumulative repousse le projet
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.
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. Elle
se glisse, et le projet entier se décale avec elle.
É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.
**Ce n'était pas le cas au départ, et le motif du refus mérite d'être conservé** : 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 qu'on voie ce qui bouge. L'argument était
juste ; c'est le premier usage réel de l'outil qui l'a renversé. Un projet engagé glisse dans le
temps — c'est même la chose qui arrive le plus souvent —, et le faire glisser phase par phase oblige
à répéter le même geste cinq fois en priant pour que les écarts se conservent. Or ils ne se
conservent pas : chaque phase s'accroche à son propre lundi, et un projet ainsi déplacé se
déforme un peu à chaque passage.
L'objection d'origine n'a pas été balayée, elle a été traitée :
- **Les barres de phase suivent le résumé pendant le geste.** On voit exactement ce qu'on déplace
pendant qu'on le déplace, y compris plié — où les segments colorés de la barre suffisent à le
montrer. C'était le cœur du reproche, et il tombe.
- **Le delta est calculé une fois, sur la première phase qui bouge, puis appliqué tel quel
partout.** Les écarts entre phases sont conservés au jour près : le projet est repoussé, il n'est
pas replanifié. C'est précisément ce que le geste phase par phase ne savait pas faire.
- **La référence figée ne suit pas.** Repousser de trois semaines affiche aussitôt une dérive de
trois semaines. Le geste ne cache pas ce qu'il coûte, il l'inscrit.
### Ce qui est fait est fait
Une phase `done` ne bouge pas. Décaler un projet, c'est reconnaître qu'il prendra plus de temps que
prévu, et emporter les phases terminées réécrirait un passé qu'on a vécu — en effaçant du même coup
la dérive qu'on cherche à lire.
Le geste ne « déplace » donc pas toujours : sur un projet à moitié fait, la barre s'**étire**, son
bord gauche tient et le reste s'en va. C'est assumé, et c'est même ce qu'on veut voir. Le résumé se
repose alors depuis le modèle à chaque mouvement de souris plutôt que d'être translaté, faute de quoi
l'aperçu et le résultat divergeraient.
Le critère est le **statut**, jamais la position dans le calendrier : une phase terminée en avance
reste où elle est, une phase `blocked` ou `doing` déjà commencée se décale avec le reste — c'est bien
elle qui glisse. Un projet dont toutes les phases sont closes n'a rien à repousser : sa barre perd
son `data-role`, garde le curseur par défaut, et le dit dans son infobulle plutôt que d'offrir une
prise qui ne mènerait nulle part.
Rien n'empêche un décalage vers l'arrière de faire chevaucher une phase avec une phase terminée. Il
n'y a pas de dépendances entre phases dans ce modèle (section 2) et poser ici une butée en
inventerait une par la bande.
### Pas de poignées
La barre cumulative se déplace, elle ne se redimensionne pas. Étirer un projet supposerait de dilater
chaque phase au prorata, avec des arrondis qui déforment les durées courtes et un résultat qu'on ne
peut pas prévoir avant de l'avoir vu. C'est un autre besoin, et il attendra de se présenter.
Le même geste existe au clavier : sur la ligne d'un projet, `←` et `→` le repoussent d'un jour, là où
elles ne faisaient rien. `Maj` n'y ajoute rien, pour la raison qui précède.
### Ce qui n'a pas changé
**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
@@ -120,8 +167,10 @@ en train d'éditer.
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`.
puisqu'elle voisine avec elles dès que le projet est déplié. Le fantôme de référence se cale dessous,
d'où sa variante `--cumulatif`. Étant mince, elle se repère mal une fois saisie : un anneau à sa
couleur la décolle du couloir le temps du geste, sans toucher à sa géométrie, ce qui décalerait ce
qu'on est en train de viser.
## 9. Accroche à la semaine
@@ -827,3 +876,414 @@ vont par paires. La génération aurait produit une liste exacte et illisible.
Elle se lit, elle ne s'édite pas (décision 24) : rien n'y aurait de sens à désigner. `j`, `k`, `gg`
et `G` y font donc ce qu'ils font dans un document — ils déroulent la liste. `gm` et `ga` marchent
dans les deux vues, le reste attend le retour à la frise.
## 27. Le cycle de vie d'un projet, et l'horizon
L'outil est employé pour piloter les chantiers d'une petite mairie : beaucoup plus de projets
imaginables que de temps pour les mener, et une crainte première — **ne rien oublier**. Une idée
qu'on ne peut pas traiter cette année ne doit pas disparaître pour autant.
Or l'outil ne connaissait que les projets déjà planifiés. Tout ce qui n'était pas encore engagé
vivait ailleurs — un carnet, une tête, un compte rendu de réunion — de sorte que l'arbitrage se
rendait **sans voir le planning**, alors que c'est précisément le planning qui dit s'il y a la
place.
### La priorité est une décision d'avant l'engagement
Le point de départ était une matrice d'Eisenhower dans le panneau d'un projet. Deux objections
l'ont déplacée.
L'urgence est **déjà dans les dates**. La resaisir crée un champ qui se périme en silence : un
projet coché « urgent » en juin ne l'est plus en septembre, et rien ne viendra le décocher.
Surtout, une fois un projet planifié, **l'arbitrage est déjà rendu** — ce sont ses dates qui le
portent. Une priorité posée sur un projet engagé ne déciderait plus rien, elle commenterait. Le
lieu de la priorisation est donc en amont, sur ce qui n'est pas encore engagé.
### Quatre états, dont deux se déduisent
Un projet traverse quatre états, et deux d'entre eux se lisent dans les données sans qu'on ait
rien à tenir à jour :
| État | Comment on le sait |
|---|---|
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
| **Engagé** | on lui a donné au moins une phase datée |
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
| **Écarté** | on a décidé de ne pas le faire |
Une règle unique les résume : **les phases commandent l'état, sauf quand on a prononcé quelque
chose.** Livré n'est pas clos, et abandonner est une décision — ces deux-là s'actent. Le reste se
déduit.
**Clore un projet termine toutes ses phases avec lui.** Sans cela la frise se contredirait : une
ligne éteinte au-dessus de segments pâles, et la vue par mois annonçant des tâches à venir dans un
projet fini. C'est donc la seule opération du cycle qui **écrase des données** — le statut d'une
phase bloquée est perdu, et rouvrir le projet ne peut pas le deviner. Elle se confirme à ce titre,
comme une suppression (décision 21), mais **seulement quand il reste quelque chose à écraser** : un
dialogue qui ne prévient de rien apprend à cliquer sans lire.
Engager n'est donc pas un bouton : c'est un geste, celui de donner ses premières dates fermes. Et
écarter n'est pas supprimer — le projet quitte la frise mais reste retrouvable, avec dans ses notes
la raison pour laquelle on a dit non. Les mêmes idées reviennent tous les deux ans, portées par
d'autres : savoir qu'on les a déjà examinées, et sur quel motif, a de la valeur.
### L'horizon est le repli des bornes
Un projet envisagé n'est pas une ligne hors du temps : il porte un **horizon**, exprimé en mois —
« mars 2027 », « de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à
cet horizon. C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et
que six projets pâles s'y superposent, on décide devant l'évidence plutôt que dans l'abstrait.
C'est ce qui remplace le calcul de charge que l'outil ne fera jamais.
La règle tient en une phrase, et elle ne mélange pas le saisi et le calculé :
> L'emprise temporelle d'un projet se calcule depuis ses phases. Un projet qui n'en a pas encore la
> déclare à la main : c'est son horizon.
Les phases gagnent toujours. `bornesProjet()` fait déjà ce calcul et retourne `null` quand il n'y a
aucune phase — l'horizon bouche ce trou, il n'ajoute pas de règle. Corollaire : **on ne réécrit
jamais une saisie par un calcul**. Un projet qui perdrait sa dernière phase retrouve l'horizon qu'il
avait déclaré, pas l'enveloppe des phases disparues.
Le grain est le mois, jamais l'année : en début de mandat, tout est à faire, et un horizon annuel ne
range plus rien. Un horizon s'exprime comme un intervalle de mois plutôt que par des crans nommés —
ni trimestre ni semestre à apprendre, un seul mécanisme, et trois propriétés qui en découlent :
- **la largeur de la barre pâle *est* l'incertitude**, l'œil la lit sans qu'on l'écrive ;
- **la précision de l'horizon suit la maturité du projet** : on resserre à mesure qu'on y voit
clair. Il n'y a pas de frontière entre envisagé et engagé, il y a un dégradé ;
- **engager, c'est préciser au jour près** ce qui était au mois près.
La barre d'un projet envisagé se dessine donc autrement : pâle, aux bords fondus, et **accrochée au
mois** — jamais au jour. C'est l'accroche au lundi de la décision 9 poussée d'un cran : le geste
existe, il ne permet simplement pas de poser une date qu'on n'a pas.
Elle fut d'abord rendue **immobile**, au motif qu'interdire physiquement valait mieux qu'une
convention. L'usage a tranché autrement, et pour une bonne raison : on ajuste un horizon *en
regardant ses voisins*, pour voir si le printemps 2027 est déjà chargé. Obliger à passer par un
panneau pour ce réglage-là, c'est le faire à l'aveugle. L'accroche au mois suffisait à protéger ce
qui devait l'être.
L'horizon **survit à l'engagement**. Non pour le comparer à ce qui a été planifié, mais parce que ne
rien effacer est ici plus simple qu'effacer, et parce qu'il sert de filet si le projet se dépeuple.
### Un horizon échu remonte à la surface
Un backlog échoue toujours de la même façon : tout ce qu'on n'ose pas refuser y atterrit, il gonfle,
plus personne ne le relit. Les deux garde-fous habituels — un plafond, une péremption qui efface —
sont ici interdits par la crainte de départ.
L'horizon en fournit un meilleur : **un projet dont l'horizon est dépassé se signale**. On est en
juin, quatre projets visaient le premier trimestre : il faut trancher, on repousse ou on écarte.
Rien ne disparaît, mais rien ne dort — et la relecture tombe d'elle-même au rythme des arbitrages
budgétaires.
### Ce qui ancre un projet écarté
La question n'a pas la même réponse selon d'où vient l'écartement.
Un projet écarté **depuis l'état engagé** garde ses phases datées, dont certaines ont réellement eu
lieu. Il a un ancrage, et un vrai : le travail fait ne doit pas s'effacer, puisque la frise est notre
mémoire.
Un projet écarté **depuis l'état envisagé** n'a rien. Son horizon a cessé d'être une prévision — on
ne le fera pas à cette date, on ne le fera pas du tout — et l'y laisser serait un mensonge sur la
frise.
D'où **la date d'écartement**, posée automatiquement, jamais saisie. C'est l'ancrage commun aux deux
cas, celui sans lequel les écartés ne forment plus qu'un tas. Il ne s'agit pas de journaliser les
décisions — les comptes rendus de réunion font ce travail, et mieux — mais d'horodater une sortie.
La clôture est horodatée de la même façon, et c'est ce qui donne au modèle sa forme définitive :
**les deux actes prononcés laissent une date, et ces deux dates sont l'unique trace de l'état dans le
fichier.** Acter, c'est dater. Il n'y a pas de champ « état » — il se lit dans ces deux dates et dans
la présence de phases.
On les retrouve d'abord en les cochant dans le filtre par état, ce qui les ramène dans la colonne
des libellés, groupés et éteints. Cela répond à « je sais que ça existe, je veux le retrouver ». Cela
ne répond pas à « relire d'un coup deux ans d'idées écartées avec leurs motifs », qui demanderait une
liste — geste assez rare pour attendre que le manque se fasse sentir.
### La réanimation ne demande aucun mécanisme
Puisque les états se déduisent, il suffit de **retirer l'écartement** : le projet retrouve tout seul
l'état que ses données commandent. Des phases, il redevient engagé ; un horizon seul, envisagé. Pas
de destination à choisir, une seule commande.
Le système se referme bien : un projet écarté en 2026 et réanimé aujourd'hui revient avec un horizon
périmé, donc se signale aussitôt comme échu. On le redate dans la foulée.
```
écarté ←──────┐
↗ ↑ │ réanimer
/ │ │ (l'état se
envisagé ──────┴─ engagé ──→ terminé recalcule)
(un horizon) (≥ 1 phase) (acté)
↑ │
└──────────────┘
s'il perd sa dernière phase
```
### L'état ne passe pas par les tags
Un tag est libre, disparaît quand plus personne ne le porte, et dérive orthographiquement
(décision 16) : trois propriétés inacceptables pour un état. Ce sont deux mécanismes indépendants
qui filtrent la même frise, et les tags restent disponibles pour ce à quoi ils servent.
Les deux filtres ne se comportent d'ailleurs pas pareil, et il faut que cela se voie : **celui des
tags est conjonctif, celui des états est disjonctif**. Un projet porte plusieurs tags mais un seul
état — cocher `client` + `urgent` resserre, cocher `envisagé` + `engagé` élargit. La même case à
cocher voudrait sinon dire deux choses opposées selon la ligne où elle se trouve.
Comme celui des tags, le filtre par état **n'est pas enregistré** : c'est un état de vue, pas un
réglage.
### Ce que le premier essai a corrigé
Trois choses ne se sont vues qu'en manipulant l'outil, et toutes disaient la même : **un projet
envisagé ne doit pas être un citoyen de seconde zone**.
**Engager démarre à l'horizon.** La première phase d'un projet envisagé naissait à la date du jour,
comme celle de n'importe quel projet vide. Elle apparaissait donc à des mois de la barre pâle qu'elle
venait remplacer, et le lien entre les deux se perdait. Elle commence maintenant au premier jour de
l'horizon : engager, c'est préciser au jour près ce qu'on avait déclaré au mois près, pas repartir
d'ailleurs.
**Les deux actes sont dans le panneau, pas seulement dans le menu.** Ils avaient été rangés dans le
menu ⋯ au nom de la décision 20 — un bouton permanent se mérite, et clore un projet est rare. Mais
c'est dans le panneau qu'on **lit** l'état du projet, donc c'est là qu'on cherche à en changer : les
y chercher et ne pas les trouver coûte plus cher que deux boutons de plus. Ils figurent aux deux
endroits, comme « plier » ou « masquer ».
**Les projets sans phase paraissent dans la vue par mois.** Ils n'y figuraient pas, faute de phase à
lister — alors qu'un projet visé pour mars 2027 *arrive* bel et bien en mars 2027. Les omettre
revenait à dire que la liste ne montre que l'engagé, quand toute cette décision pose le contraire.
Ils s'y rattachent au premier mois de leur horizon, dans les mêmes colonnes que les phases mais en
italique. C'est ce qui a fait renommer `phasesParMois` en `entreesParMois` : la liste ne montre plus
seulement des phases.
La règle porte sur **l'absence de phases et non sur l'état**, exactement comme `empriseProjet` : un
projet écarté avant d'avoir jamais été engagé n'a lui non plus que son horizon pour se situer, et
il doit se retrouver là quand on ouvre le cimetière. La distinguer par l'état aurait fait du
cimetière de la liste par mois une collection des seuls projets qui avaient démarré — soit
l'inverse de ce qu'on y cherche.
### Ce qui a été reporté, et pourquoi
Ces choix ne sont pas des refus définitifs. Ils attendent leur moment, et il vaut mieux savoir
lequel.
**La matrice d'Eisenhower.** Elle n'a pas de sens sur des projets engagés, pour les raisons
ci-dessus. Sur les seuls envisagés elle redevient cohérente, et se branche même bien : l'horizon
fournit l'axe d'urgence, il ne resterait que l'importance à saisir. À reprendre quand une liste
existera, et seulement si l'horizon seul ne suffit pas à départager.
**Une vue liste des envisagés.** La frise sait situer, elle ne sait pas ranger : on ne peut pas
ordonner six projets par priorité dans un empilement de couloirs. Cette première étape ne livre donc
**aucun mécanisme d'ordre explicite entre projets** — ce qu'elle livre, c'est l'horizon, et c'est
déjà l'arbitrage le plus décisif. Reste le départage fin entre projets visant le même trimestre.
**Un dézoom de l'axe temporel**, pour embrasser plusieurs années d'un coup. Le jour où il arrivera,
il faudra rouvrir la décision 14, « une seule échelle, pas de zoom ».
**Une vue de rétrospective.** La frise défile en arrière sans limite (décision 15) : le passé y est
déjà lisible, et un écran de plus l'aurait redit. Corollaire à tenir : **un projet terminé reste sur
la frise**, éteint mais présent, sinon on perd le bilan.
**Des dates réelles à côté des dates prévues.** Ce serait doubler chaque champ pour un gain nul :
dans cet outil on recale les barres au fil de l'eau, donc la date de fin *devient* le réel. La
contrepartie est assumée — la qualité du bilan dépend de l'entretien de la frise, et aucun champ ne
répare un planning qu'on ne tient pas.
**Un repère de fin de mandat**, comme le repère « aujourd'hui ». Pertinent sur le principe, sans
urgence en début de mandat.
**Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.
## 28. Une date qu'on ne décide pas
Toutes les phases d'un planning ne sont pas nôtres. Certaines sont **déléguées** — un prestataire les
tient, on n'a que le résultat —, d'autres portent une **contrainte extérieure** : une fenêtre de
maintenance chez l'hébergeur, une échéance réglementaire, une date de salon. Une phase reçoit donc un
booléen `fixed`, et le décalage d'un projet (section 8) ne l'emporte pas.
Sans elle, le geste ajouté en section 8 était activement nuisible : repousser un projet de trois
semaines repoussait aussi l'audit du commissaire aux comptes, ce qui est faux et ce que rien ne
signalait.
### Pourquoi ce n'est pas un cinquième statut
C'était la formulation spontanée — « ajouter un état ». Elle a été écartée, parce que les quatre
statuts existants disent tous **l'avancement** (section 6), et qu'une date imposée n'est pas une
étape du travail : une phase déléguée est tour à tour à venir, en cours, terminée, et elle reste
déléguée tout du long. En faire une cinquième valeur aurait créé une exclusion mutuelle fausse — on
ne pourrait plus dire d'une phase déléguée qu'elle est bloquée, ce qui est précisément la situation
qu'on veut voir venir de loin.
Le modèle avait déjà le patron : **`milestone` est un booléen à côté de `status`**, pas un statut. Un
jalon peut être `todo` ou `done` sans que cela pose la moindre question. `fixed` suit exactement le
même chemin, et les deux se combinent librement — un jalon à date imposée est le cas le plus courant
des deux.
**À ne pas confondre avec `blocked`**, dont il est le voisin immédiat dans un formulaire :
`blocked` dit *ça n'avance pas*, `fixed` dit *cette date ne m'appartient pas*. Une phase peut être
les deux, l'une ou l'autre, ou aucune. Le libellé retenu — « date imposée » plutôt que « fixe » —
sert d'abord à cela : il nomme la cause, là où « fixe » n'aurait nommé que l'effet, et six mois plus
tard c'est la cause qu'on cherche en relisant.
### Le verrou est souple
Une phase à date imposée **se glisse toujours seule**, à la souris comme aux flèches. Seul le
décalage de son projet l'épargne. « Imposée » veut donc dire *indépendante du projet*, et non
*immuable* : quand l'hébergeur déplace son créneau, on déplace la barre, sans rien avoir à décocher.
Le verrou dur avait été envisagé — barre non glissable, dates saisies au panneau — au motif qu'on
marque une phase pour la protéger d'un geste accidentel. Il ajoutait une friction permanente pour un
risque occasionnel, et il aurait fait diverger cette case de son modèle, `milestone`, qui n'empêche
rien non plus.
Corollaire : un projet dont toutes les phases sont terminées **ou** imposées n'offre aucune prise, et
sa barre cumulative le dit dans son infobulle. C'est le comportement déjà écrit pour les projets
entièrement clos ; il n'a pas eu à changer, seule la liste des phases emportées s'est resserrée.
### Ce que ça donne à l'œil
> Deux **taquets** verticaux aux extrémités de la barre, comme les butées d'une pièce qui ne coulisse
> plus ; un **cercle** pour un jalon, qui n'a pas d'extrémités où en poser.
C'est ce qui avait été retenu ici, et c'est ce que la section 29 a défait : la marque était trop
faible et mal placée. Elle est aujourd'hui un **cadre tireté** qui cerne la forme entière, barre ou
losange. Le reste du raisonnement tient toujours — elle se cumule avec les quatre textures de statut
au lieu de leur disputer la place, et ne touche pas à la géométrie : la barre garde exactement la
largeur de ses dates.
### Le format passe en version 6
Un champ ajouté n'y obligerait pas — la v5 se relit sans encombre. Ce qui y oblige, c'est le sens du
numéro : le validateur reconstruit chaque phase champ par champ et laisse tomber ce qu'il ne connaît
pas. Sans incrément, un binaire antérieur ouvrirait un fichier v6 sans broncher et en effacerait
toutes les dates imposées à la première sauvegarde — les phases concernées se remettant alors à
suivre le décalage de leur projet, en silence. C'est exactement le genre de perte que ce numéro
existe pour empêcher.
La migration ne devine rien : toute phase héritée reçoit `false`. Rien dans les données ne permettrait
d'inférer laquelle était déléguée, et un faux positif ici immobiliserait une phase sans qu'on
comprenne pourquoi.
Au passage, `donneesInitiales` dans `main.go` a été remis d'aplomb : le serveur écrivait encore
`version 4` pour un planning vide, deux versions en retard. Sans conséquence — le validateur accepte
un fichier plus ancien et le remonte — mais un fichier neuf se déclarait d'avant le cycle de vie.
## 29. Rendre lisibles le statut et la date imposée
La section 28 a posé la date imposée ; l'usage a montré que ni elle ni le statut ne se lisaient bien
là où on les cherche. Quatre corrections, de la plus factuelle à la plus discutable.
### Le libellé d'une phase vit dans le modèle
« En cours — date imposée, non emportée par un décalage du projet » se recomposait sur quatre sites :
les trois infobulles de la frise, et celle de la vue par mois. La quatrième avait oublié la mention,
si bien que la vue des échéances — celle où une date déléguée compte le plus — taisait ce que la
frise annonçait sur chaque barre.
D'où `libelleStatut(phase)` dans `model.js`, à côté de la table dont il se sert. Le mettre dans une
vue aurait reposé la même question à la suivante ; le modèle est le seul endroit que les deux vues
connaissent déjà. La vue par mois a reçu du même coup la marque d'une date imposée sur sa puce de
ligne, qui reprend déjà les formes de la frise : le vocabulaire visuel est le même des deux côtés, on
ne l'apprend qu'une fois.
### Le statut passe de la liste déroulante à quatre boutons
Une liste déroulante montre une valeur sur quatre, n'en dessine aucune, et demande deux clics. Les
quatre boutons montrent les quatre, chacun peint de la **texture qu'il donne à la barre**, et le
panneau devient ainsi la légende que l'outil n'avait nulle part — au seul moment où on en a besoin,
celui du choix, et sans coûter d'espace permanent au planning.
Une légende posée sur la frise avait été envisagée d'abord. Elle occupe de la place en permanence
pour une chose qui s'apprend une fois, et elle se lit loin de l'objet qu'elle explique.
Les textures se peignent dans la **couleur du projet qu'on édite** : `detail.js` pose
`--couleur-projet` sur le formulaire à l'ouverture. Le repli se déclare sur `.panneau__corps`, donc
sur l'élément même que le style inline surcharge — le déclarer sur le groupe de boutons faisait
l'inverse, une valeur posée sur un descendant gagnant toujours contre celle dont il hérite. C'est le
genre de détail qui se paie en minutes de recherche : la teinte du repli est une couleur plausible,
et rien ne signale qu'elle n'est pas celle du projet.
### Les quatre textures sur une seule échelle
`doing` était un aplat de la couleur du projet, `done` le même aplat assourdi d'un gris fixe. Deux
nuances de la même teinte, qu'on ne distinguait pas en parcourant un couloir en diagonale — alors que
*ce qui avance / ce qui est fait* est la distinction qu'on lit le plus souvent.
Deux corrections, et la seconde n'a été possible que grâce à la première.
**`doing` reçoit une rayure**, comme `blocked` en a une : le motif est le même, la teinte dit lequel
des deux on regarde — du rouge pour une phase bloquée, la couleur du projet mêlée à elle-même diluée
pour une phase qui avance.
**`done` redevient vif.** Le gris qui l'assourdissait n'était là que pour le séparer de `doing` ; la
rayure s'en charge désormais, et la teinte n'a plus à s'en mêler. C'est un gain net : sur un projet
avancé, la majorité des barres sont terminées, et les éteindre toutes donnait une frise morte où le
travail accompli avait l'air d'un brouillon. Une dilution à mi-chemin avait été essayée d'abord —
même famille de couleur, mais toujours délavée, donc toujours le même reproche.
La grammaire tient maintenant en deux traits, et c'est cette économie qui la rend lisible :
- **plein** pour un état stable, **rayé** pour ce qui bouge ou coince ;
- la couleur du projet **pleine** dès qu'une phase a commencé, **diluée** tant qu'elle n'a pas
commencé.
D'où : à venir dilué et bordé, en cours plein et rayé, terminé plein et nu, bloqué rayé de rouge —
seule teinte étrangère, parce que c'est la seule chose qui ne parle pas d'avancement. Aucune des
quatre ne se lit à une nuance qu'il faudrait comparer à une autre pour la reconnaître.
Le contraste de la rayure a été resserré au passage — la bande claire descend à 50% de dilution
plutôt que 65% : face à un `done` désormais plein, une rayure trop douce se serait lue comme un aplat
légèrement texturé.
La barre cumulative garde ses opacités : elle fait neuf pixels de haut, une rayure y serait du bruit,
et elle dit la forme d'ensemble d'un projet, pas le détail d'une phase.
### La date imposée se marque d'un cadre tireté
Les deux taquets de la section 28 ne tenaient pas leurs promesses, pour trois raisons qui se
cumulaient :
- sur une barre de deux jours, ils l'avalaient — la section 28 l'avait prévu et accepté ; à l'usage
ce n'est pas acceptable, une fenêtre de maintenance est *typiquement* courte, c'est le cas central
et non le cas limite ;
- sur une barre à venir, qui porte déjà une bordure, ils se lisaient comme une bordure épaissie aux
bouts, c'est-à-dire comme rien ;
- surtout, ils occupaient exactement la place où le survol révèle les **poignées de
redimensionnement**. Le signe « cette date ne vous appartient pas » tombait là où l'outil dit
« tirez ici pour la changer ».
La forme se cerne donc maintenant tout entière, d'un trait **tireté** — une barre, un losange, et
jusqu'au bloc des deux champs de date dans le panneau, qui ne montrait rien quand on cochait la case
alors que la conséquence porte précisément sur ces dates-là. Un seul signe partout, là où le losange
se cerclait et la barre se taquetait : c'était deux signes pour une même chose.
Le tiret n'est pas décoratif. Un cerclage **plein** a été essayé d'abord, et à la taille réelle des
barres on ne le distinguait pas d'une phase simplement sélectionnée. Discontinu, il ne se confond
plus avec rien — et il dit ce qu'il faut : ce n'est pas nous qui traçons cette ligne.
Sur la frise, le cadre vit sur un `::after` : un élément n'a qu'un `outline`, et c'est la sélection
qui le prend. Les deux marques se superposent donc sans se gêner sur une phase imposée qu'on vient de
sélectionner — un cadre tireté dedans, un contour plein dehors. Le losange l'affine à 1px : la
longueur des tirets suit l'épaisseur du trait, et 2px en posait de trop longs pour 14px de côté.
Dans le panneau, le padding qui porte le cadre est repris par une marge négative de même valeur : les
champs ne bougent pas d'un pixel quand la case change d'état, seul le trait apparaît.
### Ce que `detail.js` a appris de la section 28
Ajouter `fixed` y avait demandé quatre retouches dispersées — remplissage, application, rafraîchissement,
comparaison — qu'on ne savait liées qu'en les cherchant. Les champs sont maintenant une liste unique
que ces fonctions parcourent. `notes` en reste dehors : son contrôle est bâti par `notes.js` et se
remplit par lui, jamais en écrivant dans le `textarea`, sans quoi l'aperçu markdown ne suivrait pas.
Le groupe de radios a demandé un détour : `formulaire.status` n'est plus un contrôle mais la liste des
quatre, et n'est donc jamais l'élément focalisé. Sans `aLeFocus()`, le panneau réécrirait le statut
sous les flèches de qui est en train de le choisir.

View File

@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
```json
{
"version": 4,
"version": 6,
"projects": [
{
"id": "site-web",
@@ -19,6 +19,7 @@ dans un diff git et éditable à la main.
"collapsed": false,
"hidden": false,
"notes": "## Contexte\n\nPiloté par **Marie D.**\n\n- [x] cadrage validé\n- [ ] recette",
"horizon": { "start": "2026-08", "end": "2026-11" },
"baselineDate": "2026-06-12",
"phases": [
{
@@ -28,6 +29,7 @@ dans un diff git et éditable à la main.
"end": "2026-08-20",
"status": "done",
"milestone": false,
"fixed": false,
"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" }
},
@@ -38,7 +40,8 @@ dans un diff git et éditable à la main.
"end": "2026-11-02",
"status": "todo",
"milestone": true,
"notes": ""
"fixed": true,
"notes": "Date annoncée au client."
}
]
}
@@ -50,12 +53,21 @@ dans un diff git et éditable à la main.
| Champ | Type | Description |
|---|---|---|
| `version` | entier | Version du format. Vaut `4`. Sert à détecter un fichier trop ancien au chargement. |
| `version` | entier | Version du format. Vaut `6`. Sert à détecter un fichier trop ancien au chargement. |
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
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.
vide, une version `3` — celle d'avant les notes de projet — des notes vides, une version `4` — celle
d'avant le cycle de vie — des projets sans horizon ni date d'acte, une version `5` — celle d'avant
les dates imposées — des phases à `fixed: false`.
Aucune de ces migrations ne demande ni ne devine quoi que ce soit. Un projet qui a des phases est
*engagé*, un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une
date. Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se
devinent pas, fût-ce d'un planning dont toutes les phases sont finies. Et aucune phase n'est déclarée
à date imposée : rien dans les données ne dirait laquelle est déléguée, et un faux positif
immobiliserait une phase sans qu'on comprenne pourquoi.
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
@@ -73,6 +85,9 @@ effacerait les champs inconnus à la première sauvegarde.
| `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. |
| `horizon` | objet ou absent | Emprise déclarée, en mois : `{ "start": "2027-03", "end": "2027-06" }`. Fin **incluse**. C'est l'ancrage d'un projet qui n'a pas encore de phase. |
| `completedDate` | chaîne ou absent | Date à laquelle le projet a été déclaré terminé. Absent tant qu'il ne l'a pas été. |
| `discardedDate` | chaîne ou absent | Date à laquelle le projet a été écarté. Absent tant qu'il ne l'a pas été. |
| `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. |
@@ -86,6 +101,7 @@ effacerait les champs inconnus à la première sauvegarde.
| `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. |
| `fixed` | booléen | `true` si la date est imposée du dehors — phase déléguée, contrainte contractuelle ou réglementaire. Un décalage du projet ne l'emporte pas. |
| `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. |
@@ -103,6 +119,10 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
chiffre remplacé par un tiret. En cas de collision, un suffixe numérique est ajouté (`cadrage-2`).
Un `id` ne change jamais si le nom est modifié ensuite — il identifie, il ne décrit pas.
- `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`.
- `fixed` est orthogonal au reste : aucune combinaison n'est interdite. Une phase peut être à la fois
jalon et à date imposée — c'est même le cas le plus courant des deux —, ou `blocked` et imposée, ce
qui n'est pas une contradiction : `blocked` dit que ça n'avance pas, `fixed` que la date ne nous
appartient pas.
- `color` est un hexadécimal `#rrggbb` valide.
- `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,
@@ -110,6 +130,12 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
- `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é.
- `horizon` est facultatif, mais s'il est présent il porte deux mois `AAAA-MM` réels, et sa fin
n'est pas antérieure à son début. Il est vérifié aussi strictement que les dates d'une phase, et
non réparé en silence : il décide d'une position sur la frise, et un horizon avalé laisserait un
projet envisagé invisible sans qu'on sache pourquoi.
- `completedDate` et `discardedDate` suivent les mêmes règles que `baselineDate` : absentes, ou
dates `AAAA-MM-JJ` réelles.
## Tags
@@ -178,6 +204,58 @@ celui qui la lit. Un lien refusé n'est pas effacé : son libellé redevient du
Voir [decisions.md](decisions.md), section 25.
## Cycle de vie et horizon
L'état d'un projet — envisagé, engagé, terminé, écarté — **n'est pas un champ**. Il se lit dans les
données, et `etatProjet()` applique une règle unique : *les phases commandent l'état, sauf quand on a
prononcé quelque chose.*
| État | Comment il se lit | Ce qu'il veut dire |
|---|---|---|
| `considered` | ni `discardedDate`, ni `completedDate`, ni phase | Envisagé : un horizon pour seul ancrage |
| `engaged` | au moins une phase | Engagé : il a des dates fermes |
| `completed` | `completedDate` présente | Terminé, et quelqu'un l'a prononcé |
| `discarded` | `discardedDate` présente | Écarté : on a décidé de ne pas le faire |
Terminer et écarter s'actent, ils ne se devinent pas : un projet dont toutes les phases sont `done`
reste *engagé* tant que sa clôture n'a pas été prononcée — livré n'est pas clos. Ces deux actes
laissent une date, et ces deux dates sont l'unique trace de l'état dans le fichier. Un projet ne
porte jamais les deux à la fois ; si un fichier retouché à la main le fait, l'écartement l'emporte.
**Clore un projet passe toutes ses phases à `done`**, jalons compris. C'est la seule opération du
cycle qui écrase des données : le statut d'une phase `blocked` ou `todo` est perdu, et
`rouvrirProjet()` ne le rétablit pas — il n'a aucun moyen de savoir ce qu'il valait.
`clotureEcraseDesStatuts()` dit s'il reste quelque chose à écraser, ce qui permet à l'interface de
ne demander confirmation que dans ce cas.
**Réanimer un projet écarté** ne demande donc rien d'autre que d'effacer `discardedDate` : il
retrouve seul l'état que ses données commandent, engagé s'il a des phases, envisagé sinon. Son
horizon est resté celui d'avant et se signalera aussitôt comme dépassé, ce qui est voulu.
### L'horizon
Un projet envisagé n'est jamais hors du temps : il déclare son emprise en **mois**, fin incluse. Un
horizon d'un seul mois a `start === end`.
Le grain est le mois et jamais l'année, et l'horizon s'exprime comme un intervalle plutôt que par des
crans nommés — ni trimestre ni semestre à apprendre. La largeur de l'intervalle *est* l'incertitude,
et elle se resserre à mesure que le projet mûrit, jusqu'aux dates fermes de l'engagement.
L'horizon **survit à l'engagement** : il n'est ni effacé ni réécrit quand des phases arrivent. C'est
ce qui permet à un projet ayant perdu sa dernière phase de retrouver l'horizon qu'il avait déclaré,
plutôt que l'enveloppe de phases disparues — **on ne réécrit jamais une saisie par un calcul**.
Il se manœuvre au **mois** partout : les crans du panneau, le glisser sur la frise et le
redimensionnement de ses bords passent tous par `ajouterMois()` et n'atteignent jamais le jour.
`entreesParMois()` rattache d'ailleurs un projet envisagé au premier mois de son horizon, pour qu'il
paraisse dans la vue par mois au même titre qu'une phase qui démarre.
Un projet envisagé **sans** horizon est légal : c'est l'état de tous les projets sans phase hérités
d'un fichier en version 4. Il réclame une date, exactement comme un projet dont l'horizon est
dépassé — `projetADater()` confond volontairement les deux cas en un seul signal.
Voir [decisions.md](decisions.md), section 27.
## Dates : conventions
Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite
@@ -195,8 +273,19 @@ l'année correspondante, qui peut différer de celle de la date.
## Bornes et dérive
- Les **bornes d'un projet** vont de la plus petite `start` à la plus grande `end` de ses phases. Un
projet sans phase n'a pas de bornes et s'affiche comme un couloir vide.
- Les **bornes d'un projet** (`bornesProjet()`) vont de la plus petite `start` à la plus grande `end`
de ses phases. Un projet sans phase n'a pas de bornes.
- Son **emprise** (`empriseProjet()`) est celle de ses phases, ou à défaut celle de son horizon,
ramenée du premier jour de son premier mois au dernier jour de son dernier. C'est elle qui cadre la
fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous
envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas
d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière.
- **Repousser un projet** (`decalerProjet()`) ajoute le même nombre de jours aux `start` et `end` des
phases qu'il emporte — `phasesDecalables()` les désigne, `ancreDecalage()` donne la date sur
laquelle le geste s'accroche. Deux sortes en sont exclues, pour des raisons sans rapport : les
phases **terminées**, parce qu'on ne réécrit pas le passé, et les phases à **date imposée**, parce
qu'on n'en décide pas. Les `baseline` ne bougent pas non plus : le décalage se lit alors
intégralement dans la dérive.
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
refiger après un arbitrage assumé repart d'une base propre.

View File

@@ -54,6 +54,22 @@
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
</header>
<!--
Filtre par état, masqué tant que tous les projets sont dans le même : lui
aussi n'apparaît que le jour où il sert.
Il ne se comporte pas comme celui des tags, et c'est pourquoi il ne lui
ressemble pas. Un projet porte plusieurs tags mais un seul état : cocher un
tag de plus resserre la sélection, cocher un état de plus l'élargit. D'où
« Afficher » ici et « Filtrer » en dessous, et deux formes distinctes —
sans quoi la même case voudrait dire deux choses opposées selon la ligne
où elle se trouve.
-->
<div class="etats" id="etats" hidden role="group" aria-label="Afficher par état">
<span class="filtres__titre">Afficher</span>
<div class="etats__liste" id="etats-liste"></div>
</div>
<!--
Barre de filtres, masquée tant qu'aucun projet ne porte de tag : elle
n'apparaît que le jour où elle sert à quelque chose.
@@ -107,8 +123,13 @@
</button>
</p>
<!--
Deux tamis peuvent vider la vue — les tags cochés, les états décochés — et
celui qui les a resserrés ne sait pas toujours lequel a fini par tout
emporter. Le bouton relâche donc les deux d'un coup.
-->
<p class="vide" id="message-filtre" hidden>
Aucun projet ne porte tous les tags sélectionnés.<br />
Aucun projet ne passe les filtres en cours.<br />
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
</p>
@@ -144,7 +165,25 @@
<input type="text" name="name" required autocomplete="off" />
</label>
<div class="champ-double">
<!--
Les deux cases encadrent les dates, chacune du côté où elle agit :
« Jalon » décide s'il y en a une ou deux, et se lit donc avant ;
« Date imposée » qualifie celles qu'on vient de saisir, et se lit
après. Le statut ferme la marche : il parle du travail, pas du
calendrier.
-->
<label class="case">
<input type="checkbox" name="milestone" />
Jalon (une seule date, affichée en losange)
</label>
<!--
Le bloc se cerne du cadre tireté d'une date imposée quand la case
ci-dessous est cochée : elle agit sur ces deux champs-là, sa
conséquence doit se voir là où elle joue. Même dessin que sur la barre
de la frise — ce qui s'apprend d'un côté se relit de l'autre.
-->
<div class="champ-double" id="champ-dates">
<label>
Début
<input type="date" name="start" required />
@@ -155,20 +194,58 @@
</label>
</div>
<label>
Statut
<select name="status">
<option value="todo">À venir</option>
<option value="doing">En cours</option>
<option value="done">Terminé</option>
<option value="blocked">Bloqué</option>
</select>
<!--
Orthogonale au statut, et c'est le point : une phase déléguée avance,
se termine ou se bloque comme une autre. Seul son ancrage dans le temps
nous échappe (docs/decisions.md, section 28).
-->
<label class="case">
<input type="checkbox" name="fixed" />
Date imposée (déléguée, contractuelle ou réglementaire)
</label>
<label class="case">
<input type="checkbox" name="milestone" />
Jalon (une seule date, affichée en losange)
<!--
Quatre boutons plutôt qu'une liste déroulante, chacun peint de la
texture qu'il donne à la barre sur la frise. Le panneau devient ainsi
sa propre légende, au seul moment où l'on en a besoin — celui du choix
—, et sans coûter d'espace permanent au planning. Une liste déroulante
cachait trois valeurs sur quatre, n'en montrait aucune texture, et
demandait deux clics là où il en faut un.
Les quatre `input` partagent le nom `status` : `formulaire.status`
reste ce qu'il était pour detail.js, une valeur qui se lit et s'écrit.
-->
<fieldset class="statuts">
<legend>Statut</legend>
<!--
Les quatre choix vivent dans un `div` et non directement sous le
`fieldset` : un `legend` n'est pas un enfant comme les autres, et les
navigateurs le sortent du flux de son parent — le mettre en grille
aurait fait dépendre la mise en page d'un cas particulier.
-->
<div class="statuts__choix">
<label class="statut statut--todo">
<input type="radio" name="status" value="todo" />
<span class="statut__marque"></span>
À venir
</label>
<label class="statut statut--doing">
<input type="radio" name="status" value="doing" />
<span class="statut__marque"></span>
En cours
</label>
<label class="statut statut--done">
<input type="radio" name="status" value="done" />
<span class="statut__marque"></span>
Terminé
</label>
<label class="statut statut--blocked">
<input type="radio" name="status" value="blocked" />
<span class="statut__marque"></span>
Bloqué
</label>
</div>
</fieldset>
<!--
Les notes sont bâties par `notes.js` : aperçu markdown, barre d'outils
@@ -215,11 +292,48 @@
<button type="button" id="fermer-panneau-projet" aria-label="Fermer">&times;</button>
</div>
<form class="panneau__corps" id="formulaire-projet">
<!--
Où en est le projet dans son cycle de vie. En lecture seule : l'état
se déduit des données, il ne se choisit pas dans une liste. Les deux
qui s'actent — terminer, écarter — sont dans le menu ⋯ du projet, avec
les autres décisions qu'on ne prend qu'une fois.
-->
<p class="panneau__etat" id="panneau-projet-etat"></p>
<label>
Nom
<input type="text" name="name" required autocomplete="off" />
</label>
<!--
Horizon : l'ancrage temporel d'un projet envisagé, au mois. Le bloc
disparaît dès que le projet a une phase — ce sont elles qui commandent
alors son emprise (docs/decisions.md, section 27).
-->
<fieldset class="horizon" id="panneau-projet-horizon" hidden>
<legend>Horizon</legend>
<div class="horizon__intervalle">
<label>
De
<input type="month" name="horizonStart" autocomplete="off" />
</label>
<label>
à
<input type="month" name="horizonEnd" autocomplete="off" />
</label>
</div>
<!--
Un horizon se repousse par crans et jamais au jour : c'est ce qui
garde l'imprécision visible. Les mêmes crans dans les deux sens.
-->
<div class="horizon__crans" id="panneau-projet-crans">
<button type="button" data-cran="-3" title="Avancer de trois mois">3&nbsp;mois</button>
<button type="button" data-cran="3" title="Repousser de trois mois">+3&nbsp;mois</button>
<button type="button" data-cran="12" title="Repousser d'un an">+1&nbsp;an</button>
</div>
<p class="horizon__aide" id="panneau-projet-horizon-aide"></p>
</fieldset>
<fieldset class="couleurs">
<legend>Couleur du couloir</legend>
<div id="panneau-projet-couleurs"></div>
@@ -245,6 +359,16 @@
<p class="panneau__erreur" id="erreur-projet" role="alert" hidden></p>
<!--
Les deux actes du cycle de vie. Ils sont aussi dans le menu ⋯, avec les
autres décisions qu'on ne prend qu'une fois — mais c'est ici qu'on lit
l'état du projet, donc ici qu'on cherche à en changer.
-->
<div class="panneau__actions panneau__actions--cycle">
<button type="button" id="terminer-projet"></button>
<button type="button" id="ecarter-projet"></button>
</div>
<div class="panneau__actions">
<button type="button" id="supprimer-projet" class="bouton--danger">
Supprimer le projet
@@ -301,12 +425,15 @@
</section>
<section>
<h3>Modifier la phase du curseur</h3>
<h3>Modifier la ligne du curseur</h3>
<dl class="aide">
<dt><kbd></kbd> <kbd></kbd></dt>
<dd>Décale d'un jour</dd>
<dd>
Décale d'un jour — la phase du curseur, ou, sur la ligne d'un
projet, toutes ses phases non terminées
</dd>
<dt><kbd>Maj</kbd>+<kbd></kbd> <kbd></kbd></dt>
<dd>Allonge, raccourcit d'un jour</dd>
<dd>Allonge, raccourcit la phase d'un jour</dd>
<dt><kbd>Espace</kbd></dt>
<dd>Statut suivant : à venir, en cours, terminé, bloqué</dd>
<dt><kbd>Entrée</kbd> <kbd>e</kbd></dt>

261
js/app.js
View File

@@ -17,14 +17,21 @@ import { rendreMois, revelerMoisCourant } from './mois.js';
import { creerStockage, viderAvantFermeture } from './storage.js';
import {
COULEURS,
ETATS,
LIBELLES_ETAT,
STATUTS,
ajouterJours,
ajouterPhase,
ancreDecalage,
aujourdhui,
bornesPlanning,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
decalerProjet,
ecarterProjet,
etatProjet,
figerReference,
formaterDateLongue,
libererReference,
@@ -32,11 +39,16 @@ import {
normaliserTags,
planningVide,
pourEcriture,
premierJourDuMois,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
remplacerPhase,
remplacerProjet,
rouvrirProjet,
supprimerPhase,
supprimerProjet,
terminerProjet,
teinteTag,
tousLesTags,
validerPlanning,
@@ -58,6 +70,8 @@ const refs = {
filtres: $('filtres'),
filtresListe: $('filtres-liste'),
effacerFiltres: $('effacer-filtres'),
etats: $('etats'),
etatsListe: $('etats-liste'),
alerte: $('alerte'),
alerteMessage: $('alerte-message'),
panneau: $('panneau'),
@@ -113,6 +127,18 @@ let defilementFrise = 0;
*/
let tagsActifs = [];
/**
* États affichés. État de vue lui aussi, jamais enregistré — un réglage qui
* survivrait au rechargement rouvrirait le planning amputé sans qu'on se
* souvienne pourquoi.
*
* La sélection est **disjonctive**, à l'inverse de celle des tags : un projet
* n'a qu'un état, cocher en élargit donc toujours. Et le défaut n'est pas la
* liste vide mais les trois états vivants : le cimetière ne s'ouvre que si on le
* demande, sans quoi il encombrerait la colonne dès la première année.
*/
let etatsActifs = ['considered', 'engaged', 'completed'];
/**
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
*
@@ -154,6 +180,7 @@ const panneau = creerPanneau(
formulaire: $('formulaire-phase'),
titre: $('panneau-titre'),
erreur: $('erreur-phase'),
champDates: $('champ-dates'),
champFin: $('champ-fin'),
notes: $('notes-phase'),
supprimer: $('supprimer-phase'),
@@ -197,6 +224,12 @@ const panneauProjet = creerPanneauProjet(
conteneurCouleurs: $('panneau-projet-couleurs'),
suggestions: $('panneau-projet-suggestions'),
blocSuggestions: $('panneau-projet-suggestions-bloc'),
etat: $('panneau-projet-etat'),
blocHorizon: $('panneau-projet-horizon'),
crans: $('panneau-projet-crans'),
aideHorizon: $('panneau-projet-horizon-aide'),
terminer: $('terminer-projet'),
ecarter: $('ecarter-projet'),
notes: $('notes-projet'),
supprimer: $('supprimer-projet'),
fermer: $('fermer-panneau-projet'),
@@ -211,6 +244,9 @@ const panneauProjet = creerPanneauProjet(
demanderSuppression(_idProjet, projet) {
return confirmerSuppressionProjet(projet);
},
demanderCloture(projet) {
return confirmerCloture(projet);
},
surSuppression(idProjet) {
if (curseur?.projet === idProjet) panneau.fermer();
majPlanning(supprimerProjet(planning, idProjet));
@@ -243,7 +279,7 @@ function dessiner() {
normaliserCurseur();
const aDesProjets = planning.projects.length > 0;
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const retenus = projetsRetenus();
refs.messageVide.hidden = aDesProjets;
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
@@ -257,6 +293,7 @@ function dessiner() {
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
peindreFiltres();
peindreEtats();
peindreOnglets();
if (!aDesProjets) {
@@ -265,12 +302,12 @@ function dessiner() {
}
if (vue === 'mois') {
rendreMois(refs.vueMois, planning, { tagsActifs });
rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs });
return;
}
elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs });
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs, etatsActifs });
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
// sur la frise doit se voir dans les champs.
@@ -286,6 +323,21 @@ function dessiner() {
}
}
/**
* Projets que la frise montre : ceux que les deux filtres laissent passer.
*
* Centralisé parce que quatre parcours doivent en dire autant — le rendu, mais
* aussi les déplacements du curseur clavier, qui sauteraient sur des lignes
* invisibles si leur énumération divergeait de celle de `construireLibelles`
* (timeline.js). L'œil (`hidden`) n'entre pas ici : un projet masqué garde sa
* ligne de libellé, donc sa place dans le curseur.
*/
function projetsRetenus() {
return planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
}
function localiser(idProjet, idPhase) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (!projet) return null;
@@ -365,8 +417,71 @@ function effacerFiltres() {
dessiner();
}
// ---------------------------------------------------------------------------
// Filtre par état
// ---------------------------------------------------------------------------
/**
* Reconstruit la barre des états.
*
* Elle ne paraît que si le planning en compte plus d'un : tant que tous les
* projets sont engagés — le cas d'un fichier hérité de la version 4 —, elle
* n'aurait rien à trier.
*
* Chaque état affiche son compte. C'est ce qui rend le cimetière consultable
* sans lui construire d'écran : « écartés (7) » dit à la fois qu'ils existent et
* combien, et un clic les ramène dans la colonne.
*/
function peindreEtats() {
const comptes = new Map(ETATS.map((nom) => [nom, 0]));
for (const projet of planning.projects) {
comptes.set(etatProjet(projet), comptes.get(etatProjet(projet)) + 1);
}
const presents = ETATS.filter((nom) => comptes.get(nom) > 0);
refs.etats.hidden = presents.length < 2;
refs.etatsListe.replaceChildren(
...presents.map((nom) => {
const actif = etatsActifs.includes(nom);
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'etat-filtre';
bouton.dataset.etat = nom;
bouton.textContent = `${LIBELLES_ETAT[nom]}s (${comptes.get(nom)})`;
bouton.setAttribute('aria-pressed', String(actif));
// Tout décocher afficherait le planning entier — l'inverse de ce que
// demande le geste. On bloque plutôt le dernier actif, ce qui se voit.
bouton.disabled = actif && etatsActifs.length === 1;
bouton.title = actif
? bouton.disabled
? 'Au moins un état doit rester affiché.'
: `Masquer les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`
: `Afficher aussi les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`;
bouton.addEventListener('click', () => basculerEtat(nom));
return bouton;
})
);
}
/** Ajoute l'état à l'affichage, ou l'en retire s'il y était déjà. */
function basculerEtat(nom) {
const restants = etatsActifs.filter((actif) => actif !== nom);
if (!restants.length) return;
etatsActifs = restants.length === etatsActifs.length ? [...etatsActifs, nom] : restants;
dessiner();
}
refs.effacerFiltres.addEventListener('click', effacerFiltres);
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
// Depuis la barre des tags, « Tout afficher » ne touche qu'aux tags : ouvrir le
// cimetière au passage serait une surprise. Depuis l'écran vide, en revanche, on
// ne sait pas lequel des deux tamis a tout emporté — on relâche donc les deux.
$('effacer-filtres-vide').addEventListener('click', () => {
tagsActifs = [];
etatsActifs = [...ETATS];
dessiner();
});
// ---------------------------------------------------------------------------
// Bascule entre la frise et la vue par mois
@@ -745,6 +860,33 @@ installerGlisser(refs.couloirs, {
surClic(idProjet, idPhase) {
ouvrirPhase(idProjet, idPhase);
},
trouverProjet(idProjet) {
return planning.projects.find((p) => p.id === idProjet) ?? null;
},
surDeplacementHorizon(idProjet, horizon) {
// Même convention que pour une phase : null veut dire « geste annulé ou sans
// effet », on redessine sans rien sauvegarder.
if (!horizon) {
dessiner();
return;
}
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
},
surDecalageProjet(idProjet, jours) {
// Même convention que pour une phase : null veut dire « geste annulé ou sans
// effet », on redessine sans rien sauvegarder.
if (!jours) {
dessiner();
return;
}
majPlanning(
remplacerProjet(planning, idProjet, (projet) => decalerProjet(projet, jours))
);
},
surClicProjet(idProjet) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (projet) ouvrirProjet(projet);
},
});
/**
@@ -854,6 +996,14 @@ function ouvrirMenuProjet(idProjet, ancre) {
if (!projet) return;
const aUneReference = projet.phases.some((phase) => phase.baseline);
const etat = etatProjet(projet);
// Deux actes, et deux seulement : les autres états se déduisent. Chacun se
// présente dans un sens ou dans l'autre selon où en est le projet, comme le
// font déjà « plier » et « masquer » — une entrée qui bascule plutôt que deux
// dont une serait toujours grise.
const acter = (transformation) =>
majPlanning(remplacerProjet(planning, idProjet, transformation));
menu.ouvrir(ancre, [
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
@@ -871,6 +1021,26 @@ function ouvrirMenuProjet(idProjet, ancre) {
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
},
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
{
libelle: etat === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé',
separateurAvant: true,
// Un projet envisagé n'a rien à clore : la clôture est le bout du chemin
// qui commence avec la première phase. Un écarté non plus — il sort du
// cimetière par « Remettre en jeu », et retrouve alors l'état que ses
// données commandent ; le clore d'ici court-circuiterait le cycle.
desactive: etat === 'considered' || etat === 'discarded',
action: () =>
etat === 'completed' ? acter((p) => rouvrirProjet(p)) : terminerLeProjet(projet),
},
{
// Écarter n'est pas supprimer, et la manœuvre se défait : pas de dialogue
// de confirmation, réservé à ce qui ne se rattrape pas (décision 21). Le
// projet garde tout, notes comprises, et se retrouve en cochant
// « écartés » dans le filtre par état.
libelle: etat === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet',
action: () =>
acter(etat === 'discarded' ? (p) => reanimerProjet(p) : (p) => ecarterProjet(p)),
},
{
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
separateurAvant: true,
@@ -909,8 +1079,20 @@ function ajouterUnePhase(projet, { debut = null } = {}) {
// `debut` n'est fourni que par les raccourcis `a`, `o` et `O`, qui insèrent
// ailleurs qu'au bout : les phases étant triées par date, « avant » et
// « après » ne s'expriment que par une date, jamais par un rang.
//
// La toute première phase d'un projet envisagé démarre à son horizon, et non
// aujourd'hui : c'est le moment où l'on engage, et engager veut dire préciser
// ce qu'on avait déclaré au mois près. La faire naître ailleurs donnerait à
// croire que l'horizon n'était pas pris au sérieux — et laisserait la barre
// pâle et la barre ferme à deux endroits sans rapport.
const derniere = projet.phases[projet.phases.length - 1];
if (!debut) debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
if (!debut) {
debut = derniere
? ajouterJours(derniere.end, 1)
: projet.horizon
? premierJourDuMois(projet.horizon.start)
: aujourdhui();
}
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
@@ -945,7 +1127,7 @@ function ouvrirProjet(projet, options = {}) {
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
*/
const CIBLES_OUVRANTES =
'[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]';
'[data-role="barre"], [data-role="horizon"], [data-role="poignee"], .libelle-phase, [data-action]';
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
@@ -1006,6 +1188,42 @@ function confirmerSuppressionProjet(projet) {
});
}
/**
* Clore un projet **termine toutes ses phases avec lui**, et rien ne rétablira
* ensuite celle qui était bloquée ou celle qui n'avait pas commencé : rouvrir le
* projet ne peut pas deviner ce qu'elles valaient. C'est donc une action
* irréversible au sens de la décision 21, et elle s'annonce comme telle.
*
* On ne demande rien quand plus aucune phase n'est ouverte : il n'y a alors rien
* à écraser, et un dialogue qui ne prévient de rien apprend à cliquer sans lire.
*/
function confirmerCloture(projet) {
if (!clotureEcraseDesStatuts(projet)) return Promise.resolve(true);
const restantes = projet.phases.filter((phase) => phase.status !== 'done').length;
// Deux phrases écrites en entier plutôt qu'une suite de ternaires accordés :
// le texte d'un dialogue se relit, et une accumulation de « s » conditionnels
// finit toujours par produire une conjugaison de travers.
const message =
restantes > 1
? `${restantes} phases de « ${projet.name} » ne sont pas encore terminées. Elles passeront ` +
'au statut « Terminé », jalons compris, et rouvrir le projet ensuite ne leur rendra pas ' +
'leur statut précédent.'
: `Une phase de « ${projet.name} » n'est pas encore terminée. Elle passera au statut ` +
'« Terminé », et rouvrir le projet ensuite ne lui rendra pas son statut précédent.';
return demanderConfirmation({
titreTexte: 'Déclarer le projet terminé',
messageTexte: message,
libelleValider: 'Déclarer terminé',
});
}
async function terminerLeProjet(projet) {
if (!(await confirmerCloture(projet))) return;
majPlanning(remplacerProjet(planning, projet.id, (p) => terminerProjet(p)));
}
async function supprimerLeProjet(projet) {
if (!(await confirmerSuppressionProjet(projet))) return;
@@ -1025,7 +1243,7 @@ async function supprimerLeProjet(projet) {
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
function projetVoisin(idProjet) {
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
const retenus = projetsRetenus();
const index = retenus.findIndex((p) => p.id === idProjet);
if (index === -1) return null;
const voisin = retenus[index + 1] ?? retenus[index - 1];
@@ -1052,6 +1270,12 @@ function nouveauProjet() {
// Un filtre actif préremplit les tags : quand on crée un projet en ne
// regardant que les projets « client », c'en est presque toujours un — et
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
//
// Même raison pour l'état : un projet naît envisagé, et le masquer aussitôt
// parce qu'on avait décoché « envisagés » ferait paraître le bouton sans
// effet. On rouvre donc l'état plutôt que de créer dans l'ombre.
if (!etatsActifs.includes('considered')) etatsActifs = [...etatsActifs, 'considered'];
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
majPlanning({ ...planning, projects: [...planning.projects, projet] });
ouvrirProjet(projet, { nomAEcraser: true });
@@ -1105,16 +1329,29 @@ refs.frise.addEventListener(
{ passive: false }
);
// Flèches : ajustement fin de la phase du curseur, au jour près.
// Flèches : ajustement fin, au jour près, de ce que porte le curseur.
document.addEventListener('keydown', (evenement) => {
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler.
if (!curseur?.phase) return;
if (!curseur) return;
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
if (pas === 0) return;
// Sur la ligne d'un projet, les flèches repoussent le projet entier — le même
// geste que le glisser de sa barre cumulative, au jour près. Maj n'y ajoute
// rien : un projet ne s'étire pas (docs/decisions.md, section 8).
if (!curseur.phase) {
const projet = planning.projects.find((p) => p.id === curseur.projet);
if (!projet || !ancreDecalage(projet)) return;
evenement.preventDefault();
majPlanning(
remplacerProjet(planning, curseur.projet, (aDecaler) => decalerProjet(aDecaler, pas))
);
return;
}
evenement.preventDefault();
const trouve = localiser(curseur.projet, curseur.phase);
if (!trouve) return;
@@ -1146,7 +1383,7 @@ document.addEventListener('keydown', (evenement) => {
function lignesFrise() {
const lignes = [];
for (const projet of planning.projects.filter((p) => projetFiltre(p, tagsActifs))) {
for (const projet of projetsRetenus()) {
lignes.push({ projet: projet.id, phase: null });
if (projet.hidden || projet.collapsed) continue;
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
@@ -1312,7 +1549,7 @@ function deplacerDansLeProjet(pas) {
/** `{` et `}` : la ligne du projet précédent ou suivant. */
function deplacerDeProjet(pas) {
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
const retenus = projetsRetenus();
if (!retenus.length) return;
const depart = retenus.findIndex((p) => p.id === curseur?.projet);

View File

@@ -7,9 +7,10 @@
* des séquences et le préfixe en attente.
*
* Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent
* pas : les **flèches modifient** la phase du curseur (app.js, hérité), les
* **lettres déplacent** le curseur et commandent. Aucun raccourci documenté n'a
* changé de sens.
* pas : les **flèches modifient** ce que porte le curseur — la phase, ou le
* projet entier quand il est sur sa ligne (app.js, hérité) —, les **lettres
* déplacent** le curseur et commandent. Aucun raccourci documenté n'a changé de
* sens.
*/
/**

View File

@@ -16,8 +16,31 @@
import { STATUTS, dateValide, modifierPhase } from './model.js';
import { creerBlocNotes } from './notes.js';
/**
* Les champs de la phase, et la forme de leur contrôle.
*
* Une seule liste, parcourue par tout ce qui traite les champs en bloc :
* l'affichage, le rafraîchissement et la comparaison. Ajouter `fixed` avait
* demandé quatre retouches dispersées, dont on ne savait qu'en les cherchant
* qu'elles allaient ensemble ; il n'y a plus qu'une ligne à ajouter ici, et le
* cas particulier à écrire s'il y en a un.
*
* `notes` n'y est pas : son contrôle est bâti par `notes.js` et se remplit par
* lui, jamais en écrivant directement dans le `textarea` — l'aperçu markdown ne
* suivrait pas. Il reste comparé à part, pour la même raison.
*/
const CHAMPS = [
{ nom: 'name' },
{ nom: 'start' },
{ nom: 'end' },
{ nom: 'status' },
{ nom: 'milestone', case: true },
{ nom: 'fixed', case: true },
];
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champFin, notes, supprimer, fermer } = refs;
const { panneau, formulaire, titre, erreur, champDates, 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
@@ -35,19 +58,51 @@ export function creerPanneau(refs, rappels) {
function remplir(projet, phase) {
titre.textContent = `${projet.name}${phase.name}`;
formulaire.name.value = phase.name;
formulaire.start.value = phase.start;
formulaire.end.value = phase.end;
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
formulaire.milestone.checked = phase.milestone;
for (const champ of CHAMPS) ecrire(champ, phase);
// 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 });
peindre(projet);
accorderLesChamps(phase);
afficherErreur(null);
}
/**
* Donne au formulaire la couleur du projet qu'il édite : les textures des
* quatre boutons de statut s'y peignent, comme la barre qu'on regarde
* derrière le panneau. Rejoué au rafraîchissement, faute de quoi recolorer un
* projet laisserait le panneau à sa teinte d'ouverture.
*/
function peindre(projet) {
formulaire.style.setProperty('--couleur-projet', projet.color);
}
/** Écrit un champ du modèle dans son contrôle, quelle que soit sa forme. */
function ecrire(champ, phase) {
const controle = formulaire[champ.nom];
if (champ.case) controle.checked = phase[champ.nom];
// Les quatre boutons de statut portent le même nom : leur affecter une
// valeur inconnue les décocherait tous, et le panneau n'afficherait plus
// rien là où le modèle, lui, retombe sur « à venir ».
else if (champ.nom === 'status') controle.value = statutAffiche(phase);
else controle.value = phase[champ.nom];
}
/**
* Ce que le formulaire montre en plus de ses valeurs : les champs qui n'ont
* pas lieu d'être, et la marque de ceux qu'on ne décide pas.
*
* Rejoué à chaque frappe et pas seulement à l'ouverture : les deux cases
* changent ce que le reste du panneau donne à voir, et l'effet doit suivre le
* clic qui le déclenche.
*/
function accorderLesChamps(phase) {
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
champFin.hidden = phase.milestone;
afficherErreur(null);
// Le cadre tireté de la frise, reporté sur les dates que la case qualifie.
champDates.classList.toggle('champ-double--imposee', phase.fixed);
}
/** Lit le formulaire, applique les invariants et remonte le changement. */
@@ -77,6 +132,7 @@ export function creerPanneau(refs, rappels) {
end: fin,
status: formulaire.status.value,
milestone: jalon,
fixed: formulaire.fixed.checked,
notes: formulaire.notes.value,
});
@@ -92,7 +148,7 @@ export function creerPanneau(refs, rappels) {
afficherErreur(null);
}
champFin.hidden = jalon;
accorderLesChamps(apres);
phaseCourante = apres;
if (!sontEgales(avant, apres)) {
@@ -162,15 +218,15 @@ 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']) {
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
for (const champ of CHAMPS) {
if (!aLeFocus(formulaire[champ.nom], actif)) ecrire(champ, phase);
}
// 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;
peindre(projet);
accorderLesChamps(phase);
},
fermer() {
@@ -192,12 +248,29 @@ export function creerPanneau(refs, rappels) {
}
function sontEgales(a, b) {
return (
a.name === b.name &&
a.start === b.start &&
a.end === b.end &&
a.status === b.status &&
a.milestone === b.milestone &&
a.notes === b.notes
);
return CHAMPS.every((champ) => a[champ.nom] === b[champ.nom]) && a.notes === b.notes;
}
/**
* Le statut que le formulaire doit montrer.
*
* Le modèle ramène un statut inconnu à « à venir » sans bloquer le chargement
* (model.js) ; le panneau fait le même choix, faute de quoi il n'afficherait
* rien là où la frise, elle, dessine une barre.
*/
function statutAffiche(phase) {
return STATUTS.includes(phase.status) ? phase.status : 'todo';
}
/**
* Ce contrôle a-t-il le focus ?
*
* Les quatre boutons de statut partagent un nom, si bien que `formulaire.status`
* est la liste des quatre et non l'un d'eux : elle n'est jamais l'élément
* focalisé, et sans ce détour le panneau réécrirait le statut sous les flèches
* de qui est en train de le choisir.
*/
function aLeFocus(controle, actif) {
if (controle instanceof RadioNodeList) return [...controle].includes(actif);
return controle === actif;
}

View File

@@ -1,6 +1,12 @@
/**
* Glisser et redimensionner les barres.
*
* Trois sortes de barres se saisissent, et le même appui les sert toutes :
* celle d'une **phase**, accrochée au lundi ; celle de l'**horizon** d'un projet
* envisagé, accrochée au mois ; et la barre **cumulative** d'un projet, qui
* repousse d'un coup toutes ses phases non terminées. Chacune sait ce qu'elle
* déplace par son `data-role`.
*
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
@@ -14,11 +20,21 @@
import {
ajouterJours,
ajouterMois,
ancreDecalage,
decalerProjet,
deplacerPhase,
dernierJourDuMois,
ecartJours,
ecartMois,
lundiDeLaSemaine,
moisDe,
phasesDecalables,
premierJourDuMois,
redimensionnerPhase,
versUTC,
} from './model.js';
import { poserBarreCumulative } from './timeline.js';
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
const SEUIL_GLISSER = 4;
@@ -40,19 +56,76 @@ export function installerGlisser(conteneur, rappels) {
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
if (evenement.button !== 0) return;
const cible = evenement.target.closest('[data-role="barre"]');
const cible = evenement.target.closest(
'[data-role="barre"], [data-role="horizon"], [data-role="cumul"]'
);
if (!cible) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
// La barre cumulative repousse le projet entier. Elle n'a pas de poignées :
// étirer un projet supposerait de dilater chaque phase au prorata, ce qui
// n'est pas le même besoin (docs/decisions.md, section 8).
if (cible.dataset.role === 'cumul') {
const projet = rappels.trouverProjet(cible.dataset.projet);
const ancre = projet && ancreDecalage(projet);
if (!ancre) return;
geste = {
element: cible,
sorte: 'cumul',
idProjet: projet.id,
projetInitial: projet,
ancre,
jours: 0,
mode: 'deplacer',
xDepart: evenement.clientX,
// Les barres de phase suivent le résumé pendant le geste : sans elles,
// on repousserait plusieurs mois de travail sans voir ce qui bouge —
// l'objection qui avait d'abord fait écarter ce geste.
suiveurs: suiveursDuProjet(conteneur, projet),
demarre: false,
};
cible.setPointerCapture(evenement.pointerId);
evenement.preventDefault();
return;
}
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
// en a pas encore (docs/decisions.md, section 27).
if (cible.dataset.role === 'horizon') {
const projet = rappels.trouverProjet(cible.dataset.projet);
if (!projet?.horizon) return;
geste = {
element: cible,
sorte: 'horizon',
idProjet: projet.id,
horizonInitial: projet.horizon,
horizonCourant: projet.horizon,
mode: bord,
xDepart: evenement.clientX,
demarre: false,
};
cible.setPointerCapture(evenement.pointerId);
evenement.preventDefault();
return;
}
const { projet: idProjet, phase: idPhase } = cible.dataset;
const phase = rappels.trouverPhase(idProjet, idPhase);
if (!phase) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
const mode = poignee && !phase.milestone ? bord : 'deplacer';
geste = {
element: cible,
sorte: 'phase',
idProjet,
idPhase,
phaseInitiale: phase,
@@ -89,9 +162,29 @@ export function installerGlisser(conteneur, rappels) {
const acheve = geste;
geste = null;
acheve.element.releasePointerCapture?.(evenement.pointerId);
acheve.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
relacher(acheve, evenement.pointerId);
if (acheve.sorte === 'cumul') {
// Un appui sans glisser ouvre les paramètres du projet, comme un clic sur
// son nom ou sur sa barre d'horizon.
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
else rappels.surDecalageProjet(acheve.idProjet, acheve.jours || null);
return;
}
if (acheve.sorte === 'horizon') {
const avant = acheve.horizonInitial;
const apres = acheve.horizonCourant;
// Un appui sans glisser ouvre les paramètres du projet : c'est là que se
// saisissent son horizon et le reste, comme un clic sur son nom.
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
else if (apres.start !== avant.start || apres.end !== avant.end) {
rappels.surDeplacementHorizon(acheve.idProjet, apres);
} else {
rappels.surDeplacementHorizon(acheve.idProjet, null);
}
return;
}
if (!acheve.demarre) {
rappels.surClic(acheve.idProjet, acheve.idPhase);
@@ -110,26 +203,47 @@ export function installerGlisser(conteneur, rappels) {
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
});
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
document.addEventListener('keydown', (evenement) => {
if (evenement.key !== 'Escape' || !geste) return;
/** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */
function annuler() {
if (!geste) return;
const annule = geste;
geste = null;
annule.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
relacher(annule);
if (annule.sorte === 'cumul') rappels.surDecalageProjet(annule.idProjet, null);
else if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
}
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
document.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape') annuler();
});
// Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
conteneur.addEventListener('pointercancel', () => {
if (!geste) return;
const annule = geste;
geste = null;
annule.element.classList.remove('barre--glissee');
conteneur.addEventListener('pointercancel', annuler);
}
/**
* Rend le pointeur et retire les marques du glisser.
*
* `releasePointerCapture` lève quand le pointeur a déjà disparu — périphérique
* débranché, fenêtre qui perd le focus au mauvais moment. L'exception ne doit
* pas empêcher ce qui suit : le geste de l'utilisateur est achevé et son
* résultat mérite d'être appliqué, même si le nettoyage n'avait plus rien à
* rendre.
*/
function relacher(geste, pointerId) {
if (pointerId !== undefined) {
try {
geste.element.releasePointerCapture?.(pointerId);
} catch {
// Le pointeur n'était plus capturé : il n'y a rien à réparer.
}
}
geste.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
});
}
/**
@@ -137,6 +251,9 @@ export function installerGlisser(conteneur, rappels) {
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
*/
function appliquerGeste(geste, decalage, echelle) {
if (geste.sorte === 'cumul') return appliquerGesteCumul(geste, decalage, echelle);
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
const { phaseInitiale: phase, mode } = geste;
if (mode === 'deplacer') {
@@ -164,6 +281,88 @@ function appliquerGeste(geste, decalage, echelle) {
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
}
/**
* Éléments de la frise qu'un décalage de projet emporte, avec leur position de
* départ.
*
* La position est relue dans `style.left` plutôt que dans `offsetLeft` : le nom
* d'un jalon porte une marge, que le second compte et le premier non. Repartir
* de la valeur qu'on a soi-même posée est le seul moyen d'y ajouter un décalage
* sans en décaler deux fois.
*
* Le fantôme de référence n'en est délibérément pas : il ne porte aucune phase,
* il tient la place du plan figé, et le voir rester en arrière pendant qu'on
* repousse le projet *est* la dérive qui s'affiche.
*/
function suiveursDuProjet(conteneur, projet) {
const decalables = new Set(phasesDecalables(projet).map((phase) => phase.id));
const suiveurs = [];
for (const element of conteneur.querySelectorAll('.barre, .jalon, .jalon-nom')) {
if (element.dataset.projet !== projet.id) continue;
if (!decalables.has(element.dataset.phase)) continue;
suiveurs.push({ element, gaucheInitiale: parseFloat(element.style.left) });
}
return suiveurs;
}
/**
* Repousse un projet entier : le même nombre de jours sur toutes ses phases non
* terminées, accroché au lundi comme une phase seule (décision 9).
*
* L'accroche porte sur la première phase qui bouge, et non sur le début du
* projet — sur un projet dont les premières phases sont closes, ce début reste
* en place et ne dirait rien du geste en cours.
*
* La barre cumulative est reposée depuis le modèle plutôt que translatée : elle
* se déforme quand une partie du projet ne suit pas, et son bord gauche tient
* pendant que le reste s'en va.
*/
function appliquerGesteCumul(geste, decalage, echelle) {
const jours = ecartJours(geste.ancre, accrocher(geste.ancre, decalage, echelle));
if (jours === geste.jours) return;
geste.jours = jours;
poserBarreCumulative(geste.element, decalerProjet(geste.projetInitial, jours), echelle);
const pixels = jours * echelle.pixelsParJour;
for (const suiveur of geste.suiveurs) {
suiveur.element.style.left = `${suiveur.gaucheInitiale + pixels}px`;
}
}
/**
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
* qu'il en pose.
*
* Les bornes ne se retournent pas — la borne opposée fait butée, comme pour une
* phase —, si bien qu'un horizon fait toujours au moins un mois plein.
*/
function appliquerGesteHorizon(geste, decalage, echelle) {
const { horizonInitial: horizon, mode } = geste;
const largeur = ecartMois(horizon.start, horizon.end);
let suivant;
if (mode === 'deplacer') {
const debut = accrocherAuMois(horizon.start, decalage, echelle);
suivant = { start: debut, end: ajouterMois(debut, largeur) };
} else if (mode === 'redimensionner-debut') {
const debut = accrocherAuMois(horizon.start, decalage, echelle);
suivant = { start: debut > horizon.end ? horizon.end : debut, end: horizon.end };
} else {
const fin = accrocherAuMois(horizon.end, decalage, echelle);
suivant = { start: horizon.start, end: fin < horizon.start ? horizon.start : fin };
}
geste.horizonCourant = suivant;
const gauche = premierJourDuMois(suivant.start);
const droite = dernierJourDuMois(suivant.end);
geste.element.style.left = `${echelle.x(gauche)}px`;
geste.element.style.width = `${echelle.largeurDe(gauche, droite)}px`;
}
/**
* Décale une date du nombre de jours correspondant au déplacement, puis
* l'accroche au lundi de sa semaine.
@@ -173,6 +372,21 @@ function accrocher(date, decalagePixels, echelle) {
return lundiDeLaSemaine(ajouterJours(date, jours));
}
/**
* Décale un mois du déplacement demandé, puis l'accroche au mois le plus proche.
*
* L'arrondi se fait à la moitié du mois atteint plutôt qu'à un nombre fixe de
* jours : les mois n'ont pas tous la même longueur, et découper en tranches de
* trente jours ferait dériver le geste d'un cran au fil de l'année.
*/
function accrocherAuMois(mois, decalagePixels, echelle) {
const jours = Math.round(decalagePixels / echelle.pixelsParJour);
const cible = ajouterJours(premierJourDuMois(mois), jours);
const moisCible = moisDe(cible);
const longueur = Number(dernierJourDuMois(moisCible).slice(8, 10));
return Number(cible.slice(8, 10)) > longueur / 2 ? ajouterMois(moisCible, 1) : moisCible;
}
/**
* Décale une phase au clavier, pour un ajustement fin sans souris.
* Utilisé par app.js sur les flèches gauche/droite quand une phase est

View File

@@ -15,18 +15,35 @@
/**
* 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'incmenter — 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.
* Elle a changé pour la dernière fois quand la phase a reçu sa date imposée —
* `fixed` (v6) ; avant cela, quand le projet a reçu son cycle de vie (v5). Un
* champ ajouté n'oblige pourtant à rien : la v5 se relit sans encombre, et le
* validateur ne refuse qu'un fichier *plus 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 v6 sans broncher et en effacerait toutes les
* dates imposées à la première sauvegarde : les phases concernées se remettraient
* alors à suivre le décalage de leur projet, en silence.
*/
export const VERSION_FORMAT = 4;
export const VERSION_FORMAT = 6;
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
/**
* États d'un projet, dans l'ordre du cycle de vie.
*
* Contrairement au statut d'une phase, ce n'est **pas** un champ du fichier : il
* se déduit de `etatProjet()`. Voir docs/decisions.md, section 27.
*/
export const ETATS = ['considered', 'engaged', 'completed', 'discarded'];
export const LIBELLES_ETAT = {
considered: 'Envisagé',
engaged: 'Engagé',
completed: 'Terminé',
discarded: 'Écarté',
};
export const LIBELLES_STATUT = {
todo: 'À venir',
doing: 'En cours',
@@ -34,6 +51,24 @@ export const LIBELLES_STATUT = {
blocked: 'Bloqué',
};
/**
* Ce qu'une phase est aujourd'hui, en une ligne : son statut, et la mention de
* sa date imposée quand elle en porte une.
*
* Les deux se lisent ensemble — « en cours, date imposée » — et occupent le même
* registre, d'où une seule phrase plutôt qu'une ligne chacun.
*
* Ce libellé vit ici, et non dans la vue qui l'affiche, parce que les deux vues
* l'écrivent : la frise sur ses barres, ses jalons et ses libellés, la vue par
* mois sur ses lignes. Recomposé sur place, il avait fini par diverger — la vue
* par mois taisait la date imposée que la frise annonçait.
*/
export function libelleStatut(phase) {
const statut = LIBELLES_STATUT[phase.status] ?? LIBELLES_STATUT.todo;
if (!phase.fixed) return statut;
return `${statut} — date imposée, non emportée par un décalage du projet`;
}
/** Palette par défaut, parcourue à la création de chaque nouveau projet. */
export const COULEURS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
@@ -56,6 +91,7 @@ export const MOIS_LONGS = [
];
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
const MOTIF_MOIS = /^\d{4}-\d{2}$/;
const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
const MS_PAR_JOUR = 86400000;
@@ -165,6 +201,58 @@ export function formaterMois(mois) {
return `${MOIS_LONGS[Number(mois.slice(5, 7)) - 1]} ${mois.slice(0, 4)}`;
}
// ---------------------------------------------------------------------------
// Mois
// ---------------------------------------------------------------------------
//
// Le mois est le grain de l'horizon d'un projet envisagé (décision 27). Comme
// les dates, il se manipule en chaîne — « AAAA-MM » se compare et se trie comme
// du texte, ce qui évite tout objet Date dans les comparaisons.
/** Vrai si la chaîne est un mois « AAAA-MM » plausible. */
export function moisValide(valeur) {
if (typeof valeur !== 'string' || !MOTIF_MOIS.test(valeur)) return false;
const numero = Number(valeur.slice(5, 7));
return numero >= 1 && numero <= 12;
}
/** Mois en cours, au format « AAAA-MM ». */
export function moisCourant() {
return moisDe(aujourdhui());
}
/** Premier jour d'un mois : « 2027-03 » → « 2027-03-01 ». */
export function premierJourDuMois(mois) {
return `${mois}-01`;
}
/**
* Dernier jour d'un mois : « 2027-03 » → « 2027-03-31 ».
*
* Le jour 0 du mois suivant est le dernier du mois demandé — `Date.UTC` fait le
* report d'année tout seul, décembre compris.
*/
export function dernierJourDuMois(mois) {
const annee = Number(mois.slice(0, 4));
const numero = Number(mois.slice(5, 7));
return depuisUTC(Date.UTC(annee, numero, 0));
}
/** Décale un mois d'un nombre de mois, éventuellement négatif. */
export function ajouterMois(mois, nombre) {
const annee = Number(mois.slice(0, 4));
const numero = Number(mois.slice(5, 7));
return depuisUTC(Date.UTC(annee, numero - 1 + nombre, 1)).slice(0, 7);
}
/** Nombre de mois de `debut` à `fin`, signé. Deux mois égaux donnent 0. */
export function ecartMois(debut, fin) {
return (
(Number(fin.slice(0, 4)) - Number(debut.slice(0, 4))) * 12 +
(Number(fin.slice(5, 7)) - Number(debut.slice(5, 7)))
);
}
/**
* Période d'une phase, abrégée pour une liste où le mois est déjà écrit en
* titre : « 12 » pour un jalon, « 12 → 30 » pour une tâche qui tient dans le
@@ -292,6 +380,106 @@ export function teinteTag(tag) {
return hachage;
}
// ---------------------------------------------------------------------------
// Cycle de vie et horizon
// ---------------------------------------------------------------------------
/**
* État d'un projet, déduit de ses données. Voir docs/decisions.md, section 27.
*
* Une seule règle : **les phases commandent l'état, sauf quand on a prononcé
* quelque chose**. Terminer et écarter sont des décisions — livré n'est pas
* clos, et abandonner n'est pas une conséquence des dates —, elles laissent
* donc une trace horodatée. Le reste se déduit : un projet qui a des phases est
* engagé, un projet qui n'en a pas encore est envisagé.
*
* Un projet ne peut pas porter les deux dates à la fois — les transitions
* ci-dessous s'en assurent —, mais un fichier retouché à la main le pourrait :
* l'écartement l'emporte, pour que la lecture reste déterministe.
*/
export function etatProjet(projet) {
if (projet.discardedDate) return 'discarded';
if (projet.completedDate) return 'completed';
return projet.phases.length ? 'engaged' : 'considered';
}
/**
* Vrai si le projet passe le filtre par état.
*
* Disjonctif, là où le filtre par tags est conjonctif (`projetFiltre`) : un
* projet porte plusieurs tags mais un seul état, si bien que cocher un état de
* plus ne peut qu'élargir la sélection. Une liste vide laisse tout passer, mais
* l'interface n'en pose jamais : elle part des états qu'on veut voir d'ordinaire
* — les écartés n'en font pas partie.
*/
export function projetFiltreEtat(projet, etatsActifs) {
if (!etatsActifs.length) return true;
return etatsActifs.includes(etatProjet(projet));
}
/**
* Emprise d'un horizon, ramenée en dates pour être dessinée : du premier jour
* de son premier mois au dernier jour de son dernier. Null si le projet n'en a
* pas.
*/
export function bornesHorizon(projet) {
if (!projet.horizon) return null;
return {
start: premierJourDuMois(projet.horizon.start),
end: dernierJourDuMois(projet.horizon.end),
};
}
/**
* Emprise temporelle d'un projet, quel que soit son état.
*
* L'emprise se calcule depuis les phases ; un projet qui n'en a pas encore la
* déclare à la main, c'est son horizon. Les phases gagnent donc toujours, et
* l'horizon n'est qu'une valeur de repli — jamais réécrite par le calcul, ce
* qui fait qu'un projet ayant perdu sa dernière phase retrouve l'horizon qu'il
* avait déclaré.
*/
export function empriseProjet(projet) {
return bornesProjet(projet) ?? bornesHorizon(projet);
}
/**
* Vrai si le projet réclame une date : envisagé sans horizon, ou dont l'horizon
* est déjà passé.
*
* C'est le garde-fou qui empêche la liste des envisagés de devenir une décharge
* (décision 27). Plutôt qu'un plafond ou une péremption qui effacerait — deux
* choses interdites par la crainte d'oublier —, un projet dont l'horizon est
* dépassé se signale : on repousse, ou on écarte.
*
* Les deux cas se confondent volontairement en un seul signal. Un projet hérité
* d'un fichier en version 4, sans phase et sans horizon, réclame une date au
* même titre qu'un projet qu'on visait pour le trimestre dernier.
*/
export function projetADater(projet, mois = moisCourant()) {
if (etatProjet(projet) !== 'considered') return false;
return !projet.horizon || projet.horizon.end < mois;
}
/**
* Horizon rendu lisible : « mars 2027 », « mars → juin 2027 »,
* « novembre 2026 → février 2027 ».
*
* L'année n'est écrite qu'une fois quand les deux bornes la partagent : comme
* pour la période d'une phase (`formaterPeriode`), son apparition en tête
* signale à elle seule le débordement.
*/
export function formaterHorizon(horizon) {
if (!horizon) return '';
if (horizon.start === horizon.end) return formaterMois(horizon.start);
const memeAnnee = horizon.start.slice(0, 4) === horizon.end.slice(0, 4);
const debut = memeAnnee
? MOIS_LONGS[Number(horizon.start.slice(5, 7)) - 1]
: formaterMois(horizon.start);
return `${debut}${formaterMois(horizon.end)}`;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
@@ -337,11 +525,15 @@ export function validerPlanning(donnees) {
}
idsProjets.add(projet.id);
if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) {
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
throw new ErreurValidation(
`Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).`
`Projet ${repere} : « ${cle} » n'est pas une date valide (${projet[cle]}).`
);
}
}
const horizon = validerHorizon(projet.horizon, repere);
if (!Array.isArray(projet.phases)) {
throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`);
}
@@ -363,6 +555,9 @@ export function validerPlanning(donnees) {
// 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 : '',
...(horizon ? { horizon } : {}),
...(projet.completedDate ? { completedDate: projet.completedDate } : {}),
...(projet.discardedDate ? { discardedDate: projet.discardedDate } : {}),
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
phases: trierPhases(phases),
};
@@ -371,6 +566,33 @@ export function validerPlanning(donnees) {
return { version: VERSION_FORMAT, projects: projets };
}
/**
* L'horizon est facultatif — aucun fichier en version 4 n'en porte — mais s'il
* est là, il est vérifié aussi strictement que les dates d'une phase et non
* réparé en silence comme le sont le statut ou les notes : il décide d'une
* position sur la frise, et un horizon avalé laisserait un projet envisagé
* invisible sans qu'on sache pourquoi.
*/
function validerHorizon(horizon, repere) {
if (horizon === undefined) return undefined;
if (!horizon || typeof horizon !== 'object') {
throw new ErreurValidation(`Projet ${repere} : « horizon » doit être un objet.`);
}
if (!moisValide(horizon.start) || !moisValide(horizon.end)) {
throw new ErreurValidation(
`Projet ${repere} : l'horizon doit porter deux mois « AAAA-MM » ` +
`(${horizon.start}${horizon.end}).`
);
}
// « AAAA-MM » se compare comme du texte.
if (horizon.end < horizon.start) {
throw new ErreurValidation(
`Projet ${repere} : la fin de l'horizon (${horizon.end}) précède son début (${horizon.start}).`
);
}
return { start: horizon.start, end: horizon.end };
}
/**
* Les tags sont facultatifs — un fichier en version 2 n'en a aucun — mais s'ils
* sont là, ils doivent être des chaînes : un tag avalé en silence parce qu'il
@@ -449,6 +671,10 @@ function validerPhase(phase, rang, repereProjet, idsPhases) {
// chargement : la valeur est cosmétique, contrairement aux dates.
status: STATUTS.includes(phase.status) ? phase.status : 'todo',
milestone: jalon,
// Absente d'un fichier en version 5, où elle vaut donc `false` : aucune date
// n'y était déclarée imposée, et rien dans les données ne permettrait de
// deviner laquelle l'était.
fixed: Boolean(phase.fixed),
notes: typeof phase.notes === 'string' ? phase.notes : '',
...(reference ? { baseline: reference } : {}),
};
@@ -479,13 +705,21 @@ export function bornesProjet(projet) {
return { start: debut, end: fin };
}
/** Étendue de tous les projets visibles réunis. Null si rien à afficher. */
/**
* Étendue de tous les projets visibles réunis. Null si rien à afficher.
*
* Elle sert à cadrer la fenêtre au premier affichage, et prend donc l'emprise
* plutôt que les seules phases : sans cela, un planning fait de projets encore
* tous envisagés s'ouvrirait sur rien. Les écartés en sont exclus — ils ne
* s'affichent pas d'ordinaire, et un projet abandonné il y a trois ans n'a pas à
* tirer la vue en arrière.
*/
export function bornesPlanning(planning) {
let debut = null;
let fin = null;
for (const projet of planning.projects) {
if (projet.hidden) continue;
const bornes = bornesProjet(projet);
if (projet.hidden || etatProjet(projet) === 'discarded') continue;
const bornes = empriseProjet(projet);
if (!bornes) continue;
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end;
@@ -572,37 +806,59 @@ export function formulerDerive(jours) {
// ---------------------------------------------------------------------------
/**
* Toutes les phases du planning regroupées par mois, projets mêlés.
* Tout ce que le planning fait arriver, regroupé par mois, projets mêlés.
*
* Une phase est rattachée au **mois de son début**, et à lui seul. Une phase de
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
*
* - une **phase**, rattachée au mois de son début et à lui seul. Une phase de
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
* répond à « qu'est-ce qui commence, et quand », question à laquelle la frise
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il y
* a de projets. La répéter dans chaque mois traversé en aurait fait un tableau
* de charge — utile, mais c'est une autre vue, et le doublon coûte la lecture
* en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite sur la
* ligne, ce qui suffit à voir qu'elle déborde.
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il
* y a de projets. La répéter dans chaque mois traversé en aurait fait un
* tableau de charge — utile, mais c'est une autre vue, et le doublon coûte la
* lecture en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite
* sur la ligne, ce qui suffit à voir qu'elle déborde.
* - un **projet sans phase**, rattaché au premier mois de son horizon. Il n'a
* rien à lister autrement, donc il ne paraissait nulle part ici — alors qu'un
* projet visé pour mars 2027 *arrive* bel et bien en mars 2027. L'omettre
* revenait à dire que la liste ne montre que l'engagé, quand toute la
* conception pose qu'un projet envisagé n'est pas hors du temps (décision 27).
* La règle porte sur l'absence de phases et non sur l'état, exactement comme
* `empriseProjet` : un projet écarté avant d'avoir été engagé n'a lui non plus
* que son horizon pour se situer, et il doit se retrouver là quand on ouvre le
* cimetière.
*
* Les deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
* a masqué les barres n'a pas à revenir par la liste.
* Les tamis de la frise s'appliquent tous de la même façon (timeline.js) : le
* filtre par tags retire le projet, celui par état aussi, et l'œil (`hidden`)
* de même — un projet dont on a masqué les barres n'a pas à revenir par la
* liste, et un projet écarté n'a pas à peupler les mois à venir.
*
* Les mois sans rien ne sont pas représentés : intercaler « novembre 2026 —
* rien » entre deux mois pleins allongerait la liste de tout le temps mort d'un
* planning, alors que l'absence se lit déjà dans le saut d'un titre à l'autre.
*
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase: object}>}>}
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase?: object, horizon?: object}>}>}
* trié du plus ancien au plus récent, chaque mois trié par date puis par nom.
*/
export function phasesParMois(planning, tagsActifs = []) {
export function entreesParMois(planning, tagsActifs = [], etatsActifs = []) {
const groupes = new Map();
const poser = (mois, entree) => {
if (!groupes.has(mois)) groupes.set(mois, []);
groupes.get(mois).push(entree);
};
for (const projet of planning.projects) {
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
if (!projetFiltreEtat(projet, etatsActifs)) continue;
for (const phase of projet.phases) {
const mois = moisDe(phase.start);
if (!groupes.has(mois)) groupes.set(mois, []);
groupes.get(mois).push({ projet, phase });
poser(moisDe(phase.start), { projet, phase });
}
// Un projet sans horizon n'a rien à quoi se rattacher : il se signale déjà
// dans la colonne de la frise, où sa pastille « à dater » réclame une date.
if (!projet.phases.length && projet.horizon) {
poser(projet.horizon.start, { projet, horizon: projet.horizon });
}
}
@@ -612,18 +868,40 @@ export function phasesParMois(planning, tagsActifs = []) {
mois,
entrees: groupes.get(mois).sort(
(a, b) =>
versUTC(a.phase.start) - versUTC(b.phase.start) ||
versUTC(debutEntree(a)) - versUTC(debutEntree(b)) ||
a.projet.name.localeCompare(b.projet.name, 'fr') ||
a.phase.name.localeCompare(b.phase.name, 'fr')
nomEntree(a).localeCompare(nomEntree(b), 'fr')
),
}));
}
/**
* Date à laquelle une entrée commence, pour le tri. Un horizon n'ayant pas de
* jour, il prend le premier du mois — ce qui le place en tête des entrées de son
* mois, à sa place : c'est ce qui est le moins arrêté qui ouvre la liste.
*/
function debutEntree(entree) {
return entree.phase ? entree.phase.start : premierJourDuMois(entree.horizon.start);
}
function nomEntree(entree) {
return entree.phase ? entree.phase.name : entree.projet.name;
}
// ---------------------------------------------------------------------------
// Modifications
// ---------------------------------------------------------------------------
export function creerProjet(planning, nom, tags = []) {
/**
* Un projet naît **envisagé** : sans phase, avec pour seul ancrage un horizon
* d'un mois, celui où on le crée.
*
* Ce mois par défaut n'est pas une prévision, c'est une position de départ — la
* barre paraît sous les yeux, là où on regarde, et se repousse aussitôt. Naître
* sans horizon aurait été plus honnête mais invisible, et la décision 27 pose
* qu'un projet envisagé n'est jamais hors du temps.
*/
export function creerProjet(planning, nom, tags = [], mois = moisCourant()) {
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
return {
id,
@@ -633,10 +911,103 @@ export function creerProjet(planning, nom, tags = []) {
collapsed: false,
hidden: false,
notes: '',
horizon: { start: mois, end: mois },
phases: [],
};
}
/**
* Pose l'horizon d'un projet. Les bornes se remettent d'aplomb plutôt que d'être
* refusées : la saisie se fait au fil de la frappe, et un instant où la fin
* précède le début est l'état normal de quelqu'un qui écrit son intervalle.
*/
export function definirHorizon(projet, debut, fin = debut) {
return { ...projet, horizon: { start: debut, end: fin < debut ? debut : fin } };
}
/**
* Repousse — ou avance — l'horizon d'un nombre de mois, largeur conservée.
*
* C'est le seul geste qui déplace un horizon. La barre d'un projet envisagé ne
* se glisse pas au jour : l'imprécision doit rester visible, et l'interdire
* physiquement vaut mieux que de compter sur une convention (décision 27).
*/
export function decalerHorizon(projet, mois) {
if (!projet.horizon) return projet;
return {
...projet,
horizon: {
start: ajouterMois(projet.horizon.start, mois),
end: ajouterMois(projet.horizon.end, mois),
},
};
}
/**
* Acte la clôture d'un projet, et **termine toutes ses phases avec lui**.
*
* Livré n'est pas clos : c'est quelqu'un qui le prononce, même quand une phase
* traîne encore en « à venir ». Mais prononcer la clôture d'un projet dont une
* phase resterait bloquée laisserait la frise se contredire — la barre
* cumulative garderait ses segments pâles sous une ligne éteinte, et la vue par
* mois annoncerait des tâches à venir dans un projet fini.
*
* L'opération est donc **destructive** et ne se défait pas : `rouvrirProjet` ne
* peut pas savoir laquelle des phases était bloquée et laquelle était à venir.
* D'où la confirmation demandée par l'interface dès qu'une phase resterait à
* changer (docs/decisions.md, section 27).
*/
export function terminerProjet(projet, date = aujourdhui()) {
return {
...projet,
completedDate: date,
discardedDate: undefined,
phases: projet.phases.map((phase) =>
phase.status === 'done' ? phase : { ...phase, status: 'done' }
),
};
}
/** Vrai si clore le projet changerait le statut d'au moins une de ses phases. */
export function clotureEcraseDesStatuts(projet) {
return projet.phases.some((phase) => phase.status !== 'done');
}
/**
* Annule la clôture : le projet retourne à l'état que ses phases commandent.
*
* Les statuts, eux, restent à « terminé » — ils ont été écrasés à la clôture, et
* rien ne dit ce qu'ils valaient avant. C'est le prix de l'opération, et la
* raison pour laquelle elle se confirme.
*/
export function rouvrirProjet(projet) {
return { ...projet, completedDate: undefined };
}
/**
* Écarte un projet. Ce n'est pas le supprimer : il quitte la frise mais garde
* tout, notes comprises — la raison pour laquelle on a dit non a de la valeur le
* jour où la même idée revient.
*
* La date est le seul ancrage temporel qui reste à un projet jamais engagé, dont
* l'horizon a cessé d'être une prévision. Elle est posée, jamais saisie.
*/
export function ecarterProjet(projet, date = aujourdhui()) {
return { ...projet, discardedDate: date, completedDate: undefined };
}
/**
* Sort un projet du cimetière. Aucune destination à choisir : puisque les états
* se déduisent, retirer l'écartement suffit — le projet retrouve seul l'état que
* ses données commandent, engagé s'il a des phases, envisagé sinon.
*
* Son horizon, lui, est resté celui d'avant : il se signalera aussitôt comme
* dépassé, et c'est voulu.
*/
export function reanimerProjet(projet) {
return { ...projet, discardedDate: undefined };
}
export function creerPhase(projet, nom, debut, fin, options = {}) {
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
const jalon = Boolean(options.milestone);
@@ -648,6 +1019,7 @@ export function creerPhase(projet, nom, debut, fin, options = {}) {
end: jalon ? debut : fin,
status: STATUTS.includes(options.status) ? options.status : 'todo',
milestone: jalon,
fixed: Boolean(options.fixed),
notes: options.notes || '',
};
}
@@ -658,6 +1030,82 @@ export function deplacerPhase(phase, nouveauDebut) {
return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) };
}
/**
* Les phases qu'un décalage de projet emporte. Deux sortes en sont exclues, pour
* deux raisons qui n'ont rien à voir.
*
* **Ce qui est terminé.** Décaler un projet, c'est reconnaître qu'il prendra plus
* de temps que prévu — et ce qui est fait est fait. Emporter les phases `done`
* réécrirait un passé qu'on a vécu, et effacerait du même coup la dérive qu'on
* cherche justement à lire : la référence figée, elle, ne bouge pas. C'est le
* statut qui décide, jamais la position dans le calendrier : une phase terminée
* en avance reste où elle est, une phase `blocked` ou `doing` déjà commencée se
* décale avec le reste — c'est bien elle qui glisse.
*
* **Ce dont on ne décide pas.** Une phase à `fixed` porte une date qui ne nous
* appartient pas : elle est déléguée, contractuelle ou réglementaire. Repousser
* le projet ne repousse pas l'audit du commissaire aux comptes ; le décalage lui
* passe à travers, et le chevauchement qui en résulte est précisément
* l'information qu'on veut voir.
*/
export function phasesDecalables(projet) {
return projet.phases.filter((phase) => phase.status !== 'done' && !phase.fixed);
}
/**
* Date à laquelle s'accroche le geste de décalage : le début de la première
* phase qui bougera. Null si tout est terminé — il n'y a alors rien à décaler.
*
* C'est cette date, et non le début du projet, qui sert d'ancre : sur un projet
* dont les premières phases sont closes, le bord gauche de ce qui bouge doit
* suivre le pointeur, pas un bord qui reste sur place.
*/
export function ancreDecalage(projet) {
const decalables = phasesDecalables(projet);
if (!decalables.length) return null;
return decalables.reduce(
(debut, phase) => (versUTC(phase.start) < versUTC(debut) ? phase.start : debut),
decalables[0].start
);
}
/**
* Décale d'un même nombre de jours les phases que le projet emporte —
* `phasesDecalables` dit lesquelles.
*
* Le même delta partout, jalons compris : les écarts entre phases emportées sont
* conservés au jour près, ce qui est tout l'intérêt du geste — on repousse un
* plan sans le replanifier. Celles qui restent font que le projet ne se déplace
* pas tant qu'il s'étire.
*
* L'appartenance se décide **une fois**, avant de rien décaler, et se retient par
* identifiant : la parcourir au fil de la transformation reviendrait à interroger
* des phases déjà déplacées.
*
* Rien n'empêche le décalage de faire chevaucher deux phases : il n'y a pas de
* dépendances entre phases dans ce modèle (docs/decisions.md, section 2), et
* inventer une butée ici reviendrait à en poser une par la bande.
*/
export function decalerProjet(projet, jours) {
if (!jours) return projet;
const emportees = new Set(phasesDecalables(projet).map((phase) => phase.id));
return {
...projet,
phases: trierPhases(
projet.phases.map((phase) =>
emportees.has(phase.id)
? {
...phase,
start: ajouterJours(phase.start, jours),
end: ajouterJours(phase.end, jours),
}
: phase
)
),
};
}
/**
* Change une seule borne — le glisser d'un bord de barre.
* La borne opposée fait butée : une phase ne peut pas se retourner.

View File

@@ -19,15 +19,20 @@
*/
import {
LIBELLES_ETAT,
LIBELLES_STATUT,
aujourdhui,
duree,
entreesParMois,
etatProjet,
formaterDateLongue,
formaterHorizon,
formaterMois,
formaterPeriode,
libelleStatut,
moisDe,
phasesParMois,
projetFiltre,
projetFiltreEtat,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { rendreMarkdown } from './notes.js';
@@ -38,13 +43,13 @@ import { pastillesTags } from './tags.js';
*
* @param {HTMLElement} conteneur
* @param {object} planning
* @param {object} options { tagsActifs }
* @param {object} options { tagsActifs, etatsActifs }
*/
export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
const groupes = phasesParMois(planning, tagsActifs);
export function rendreMois(conteneur, planning, { tagsActifs = [], etatsActifs = [] } = {}) {
const groupes = entreesParMois(planning, tagsActifs, etatsActifs);
if (!groupes.length) {
conteneur.replaceChildren(messageVide(planning, tagsActifs));
conteneur.replaceChildren(messageVide(planning, tagsActifs, etatsActifs));
return;
}
@@ -65,8 +70,10 @@ export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
* Le cas des projets masqués mérite son message : rien à l'écran ne rappelle
* l'œil ici, puisque la liste n'a pas de colonne de libellés où le rouvrir.
*/
function messageVide(planning, tagsActifs) {
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
function messageVide(planning, tagsActifs, etatsActifs = []) {
const retenus = planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
const paragraphe = document.createElement('p');
paragraphe.className = 'mois__vide';
@@ -120,32 +127,107 @@ function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
return section;
}
/** « 4 tâches · 2 jalons », en taisant celui des deux qui vaut zéro. */
/** « 4 tâches · 2 jalons · 1 envisagé », en taisant ce qui vaut zéro. */
function resumerCompte(entrees) {
const jalons = entrees.filter(({ phase }) => phase.milestone).length;
const taches = entrees.length - jalons;
const sansPhase = entrees.filter(({ horizon }) => horizon);
// Les projets sans phase ne se comptent pas ensemble : « 3 envisagés » et
// « 3 écartés » ne disent pas du tout la même chose du mois qui vient.
const envisages = sansPhase.filter(({ projet }) => etatProjet(projet) === 'considered').length;
const ecartes = sansPhase.length - envisages;
const jalons = entrees.filter(({ phase }) => phase?.milestone).length;
const taches = entrees.length - jalons - sansPhase.length;
const morceaux = [];
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
if (jalons) morceaux.push(`${jalons} jalon${jalons > 1 ? 's' : ''}`);
if (envisages) morceaux.push(`${envisages} envisagé${envisages > 1 ? 's' : ''}`);
if (ecartes) morceaux.push(`${ecartes} écarté${ecartes > 1 ? 's' : ''}`);
return morceaux.join(' · ');
}
function construireEntree({ projet, phase }, tagsActifs) {
function construireEntree(entree, tagsActifs) {
return entree.horizon
? construireEntreeHorizon(entree, tagsActifs)
: construireEntreePhase(entree, tagsActifs);
}
/**
* Ligne d'un projet qui n'a pas de phase : mêmes colonnes que celle d'une phase,
* pour que la liste reste une grille qu'on lit en diagonale, mais un contenu qui
* dit partout que rien n'est arrêté.
*
* L'intitulé est en italique et l'emplacement du statut porte l'état : c'est
* bien la même question — qu'est-ce qui arrive, et où ça en est — posée à un
* objet qui n'a pas encore de dates. Un projet écarté avant d'avoir été engagé
* passe par ici lui aussi, et se lit « Écarté » à la même place.
*/
function construireEntreeHorizon({ projet, horizon }, tagsActifs) {
const etat = etatProjet(projet);
const ligne = document.createElement('li');
ligne.className = 'mois-entree mois-entree--envisage';
ligne.dataset.etat = etat;
ligne.style.setProperty('--couleur-projet', projet.color);
if (projet.notes) ligne.classList.add('mois-entree--commentee');
ligne.title =
`${projet.name}\n\n${LIBELLES_ETAT[etat]}${formaterHorizon(horizon)}` +
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '');
ligne.append(triangle(projet.notes, projet.name));
const marque = document.createElement('span');
marque.className = 'mois-entree__marque';
ligne.append(marque);
const dates = document.createElement('span');
dates.className = 'mois-entree__dates';
// Le mois du groupe est déjà écrit en titre : on n'en répète que la fin quand
// l'horizon déborde, comme `formaterPeriode` le fait pour une phase.
dates.textContent = horizon.start === horizon.end ? '—' : `${formaterMois(horizon.end)}`;
ligne.append(dates);
const nomProjet = document.createElement('span');
nomProjet.className = 'mois-entree__projet';
nomProjet.textContent = projet.name;
ligne.append(nomProjet);
ligne.append(pastillesTags(projet.tags, tagsActifs));
const nom = document.createElement('span');
nom.className = 'mois-entree__nom';
nom.textContent = formaterHorizon(horizon);
ligne.append(nom);
const statut = document.createElement('span');
statut.className = 'mois-entree__statut';
statut.dataset.statut = etat;
statut.textContent = LIBELLES_ETAT[etat];
ligne.append(statut);
if (projet.notes) ligne.append(notes(projet.notes));
return ligne;
}
function construireEntreePhase({ projet, phase }, tagsActifs) {
const ligne = document.createElement('li');
ligne.className = `mois-entree mois-entree--${phase.status}`;
ligne.style.setProperty('--couleur-projet', projet.color);
if (phase.milestone) ligne.classList.add('mois-entree--jalon');
// La vue par mois est celle où l'on parcourt les échéances : c'est
// exactement là qu'une date qu'on ne décide pas doit se voir. Elle ne le
// faisait pas, alors que la frise l'annonçait sur chaque barre.
if (phase.fixed) ligne.classList.add('mois-entree--imposee');
// C'est cette classe qui rend la ligne entière cliquable (app.js) : sans
// notes, il n'y a rien à déplier et le clic ne doit rien promettre.
if (phase.notes) ligne.classList.add('mois-entree--commentee');
ligne.title = infobulle(projet, phase);
ligne.append(triangle(phase));
ligne.append(triangle(phase.notes, phase.name));
// La marque reprend les formes de la frise — losange pour un jalon, barre
// pour une tâche — pour qu'on reconnaisse au même dessin ce qu'on a appris à
// lire là-bas.
// pour une tâche, cadre tireté pour une date imposée — pour qu'on reconnaisse
// au même dessin ce qu'on a appris à lire là-bas.
const marque = document.createElement('span');
marque.className = 'mois-entree__marque';
ligne.append(marque);
@@ -179,7 +261,7 @@ function construireEntree({ projet, phase }, tagsActifs) {
statut.textContent = LIBELLES_STATUT[phase.status];
ligne.append(statut);
if (phase.notes) ligne.append(notes(phase));
if (phase.notes) ligne.append(notes(phase.notes));
return ligne;
}
@@ -196,9 +278,13 @@ function construireEntree({ projet, phase }, tagsActifs) {
* Les lignes sans notes reçoivent tout de même une case vide : les colonnes de
* la grille sont comptées, et il n'y a pas de raison qu'une phase commentée
* décale toutes les autres.
*
* Il reçoit le texte et le nom plutôt que l'objet qui les porte : une ligne de
* projet envisagé déplie les notes du projet là où une ligne de phase déplie les
* siennes, et le triangle n'a pas à savoir laquelle des deux il sert.
*/
function triangle(phase) {
if (!phase.notes) {
function triangle(texte, nom) {
if (!texte) {
const vide = document.createElement('span');
vide.className = 'mois-entree__plier';
return vide;
@@ -210,9 +296,9 @@ function triangle(phase) {
bouton.dataset.action = 'notes';
bouton.setAttribute('aria-expanded', 'false');
// La ligne entière déplie, mais elle n'est pas focusable : ce bouton est le
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de
// quelle phase il parle.
bouton.setAttribute('aria-label', `Notes de ${phase.name}`);
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de quoi
// il parle.
bouton.setAttribute('aria-label', `Notes de ${nom}`);
bouton.title = 'Afficher les notes';
bouton.textContent = '▶';
return bouton;
@@ -230,10 +316,10 @@ function triangle(phase) {
* 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) {
function notes(texte) {
const bloc = document.createElement('div');
bloc.className = 'mois-entree__notes';
bloc.append(rendreMarkdown(phase.notes));
bloc.append(rendreMarkdown(texte));
bloc.hidden = true;
return bloc;
}
@@ -246,7 +332,7 @@ function infobulle(projet, phase) {
? `Jalon du ${formaterDateLongue(phase.start)}`
: `${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
` (${jours} jour${jours > 1 ? 's' : ''})`) +
`\n${LIBELLES_STATUT[phase.status]}` +
`\n${libelleStatut(phase)}` +
// 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.

View File

@@ -11,6 +11,20 @@
*/
import { creerBlocNotes } from './notes.js';
import {
LIBELLES_ETAT,
decalerHorizon,
definirHorizon,
ecarterProjet,
etatProjet,
formaterDateLongue,
formaterHorizon,
moisValide,
projetADater,
reanimerProjet,
rouvrirProjet,
terminerProjet,
} from './model.js';
export function creerPanneauProjet(refs, outils, rappels) {
const {
@@ -21,6 +35,12 @@ export function creerPanneauProjet(refs, outils, rappels) {
conteneurCouleurs,
suggestions,
blocSuggestions,
etat,
blocHorizon,
crans,
aideHorizon,
terminer,
ecarter,
notes,
supprimer,
fermer,
@@ -106,6 +126,101 @@ export function creerPanneauProjet(refs, outils, rappels) {
blocSuggestions.hidden = tagsConnus.length === 0;
}
/**
* Horizon lu dans les deux champs de mois.
*
* Une borne laissée vide prend la valeur de l'autre : on saisit souvent un
* mois unique, et exiger de le taper deux fois serait une formalité. Les deux
* vides valent « pas d'horizon » — un état légal, celui d'un projet hérité
* d'un fichier en version 4, qui se signalera comme réclamant une date.
*
* Le champ `type="month"` n'est pas un sélecteur partout : sous Firefox c'est
* un champ texte, d'où la vérification plutôt qu'une confiance faite au
* navigateur.
*/
function horizonSaisi() {
const debut = formulaire.horizonStart.value;
const fin = formulaire.horizonEnd.value;
if (!moisValide(debut) && !moisValide(fin)) return undefined;
if (!moisValide(debut)) return { start: fin, end: fin };
if (!moisValide(fin)) return { start: debut, end: debut };
return definirHorizon({}, debut, fin).horizon;
}
/**
* Le bloc « Horizon » ne paraît que pour un projet envisagé.
*
* Dès qu'une phase existe, ce sont les phases qui commandent l'emprise et
* l'horizon n'est plus qu'une valeur de repli conservée en silence : la
* proposer à la saisie laisserait croire qu'elle déplace encore quelque chose.
*/
function peindreCycleDeVie(projet) {
const nom = etatProjet(projet);
const envisage = nom === 'considered';
etat.textContent = LIBELLES_ETAT[nom] + legendeEtat(projet, nom);
etat.dataset.etat = nom;
// Chaque acte se présente dans un sens ou dans l'autre selon où en est le
// projet : une commande qui bascule plutôt que deux dont l'une serait
// toujours grise.
terminer.textContent = nom === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé';
// Un projet envisagé n'a rien à clore, et un écarté sort du cimetière par
// « Remettre en jeu » avant tout autre chose.
terminer.disabled = nom === 'considered' || nom === 'discarded';
terminer.title = terminer.disabled
? nom === 'considered'
? "Un projet envisagé n'a pas encore commencé."
: "Remettre le projet en jeu d'abord."
: nom === 'completed'
? 'Les phases resteront terminées : leur statut davant est perdu.'
: 'Toutes les phases et tous les jalons passeront à « Terminé ».';
ecarter.textContent = nom === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet';
ecarter.title =
nom === 'discarded'
? "Le projet retrouvera l'état que ses données commandent."
: 'Le projet quitte la frise mais garde tout, notes comprises.';
blocHorizon.hidden = !envisage;
if (!envisage) return;
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur au
// milieu d'une frappe, comme pour le nom et les tags.
const actif = document.activeElement;
if (formulaire.horizonStart !== actif) {
formulaire.horizonStart.value = projet.horizon?.start ?? '';
}
if (formulaire.horizonEnd !== actif) {
formulaire.horizonEnd.value = projet.horizon?.end ?? '';
}
const aDater = projetADater(projet);
aideHorizon.textContent = aDater
? projet.horizon
? 'Cet horizon est passé : le repousser, ou écarter le projet.'
: "Sans horizon, ce projet n'est situé nulle part sur la frise."
: 'Ajouter une phase engage le projet et lui donne des dates fermes.';
aideHorizon.dataset.alerte = String(aDater);
// Rien à décaler tant qu'aucun horizon n'est posé : les crans conservent la
// largeur d'un intervalle, ils n'en inventent pas.
for (const bouton of crans.children) bouton.disabled = !projet.horizon;
}
/** Ce qui suit le nom de l'état : l'horizon, ou la date de l'acte. */
function legendeEtat(projet, nom) {
if (nom === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (nom === 'engaged') {
return `${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}`;
}
if (nom === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
return ` le ${formaterDateLongue(projet.discardedDate)}`;
}
function remplir(projet) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
@@ -113,6 +228,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
blocNotes.definir(projet.notes, { reinitialiserMode: true });
peindrePalette(projet.color);
peindreSuggestions();
peindreCycleDeVie(projet);
afficherErreur(null);
}
@@ -139,6 +255,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
name: nom,
tags: tagsSaisis(),
notes: formulaire.notes.value,
// L'horizon d'un projet engagé n'est plus modifiable, mais il n'est pas
// effacé pour autant : c'est ce qui permet à un projet ayant perdu sa
// dernière phase de le retrouver plutôt qu'une enveloppe reconstituée.
horizon: etatProjet(projetCourant) === 'considered' ? horizonSaisi() : projetCourant.horizon,
...forces,
};
@@ -148,11 +268,21 @@ export function creerPanneauProjet(refs, outils, rappels) {
suivant.tags.length === projetCourant.tags.length &&
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
const memeHorizon =
suivant.horizon?.start === projetCourant.horizon?.start &&
suivant.horizon?.end === projetCourant.horizon?.end;
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
memesTags
// Les deux dates d'acte comptent ici comme les autres champs : sans elles,
// déclarer un projet terminé ne changerait aucune des valeurs comparées et
// la modification serait tue.
suivant.completedDate === projetCourant.completedDate &&
suivant.discardedDate === projetCourant.discardedDate &&
memesTags &&
memeHorizon
) {
return;
}
@@ -161,6 +291,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
titre.textContent = suivant.name;
peindrePalette(suivant.color);
peindreSuggestions();
peindreCycleDeVie(suivant);
rappels.surModification(idCourant, suivant);
}
@@ -168,6 +299,41 @@ export function creerPanneauProjet(refs, outils, rappels) {
formulaire.addEventListener('change', () => appliquer());
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
// Repousser par crans est le seul geste qui déplace un horizon : la barre de
// la frise, elle, ne se glisse pas. Les trois boutons couvrent ce qu'on fait
// réellement — reculer d'un trimestre, en gagner un, renvoyer à l'an prochain.
crans.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('[data-cran]');
if (!bouton || !projetCourant?.horizon) return;
appliquer({ horizon: decalerHorizon(projetCourant, Number(bouton.dataset.cran)).horizon });
});
// Les deux actes passent par `appliquer`, comme le reste du panneau : c'est
// lui qui compare, remonte la modification et repeint.
terminer.addEventListener('click', async () => {
if (!projetCourant) return;
if (etatProjet(projetCourant) === 'completed') {
appliquer(rouvrirProjet(projetCourant));
return;
}
// Clore termine aussi les phases : la confirmation vit dans app.js, qui la
// partage avec l'entrée du menu ⋯ — le même acte ne doit pas prévenir depuis
// un endroit et se taire depuis l'autre.
const cible = idCourant;
if (!(await rappels.demanderCloture(projetCourant))) return;
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
if (idCourant !== cible || !projetCourant) return;
appliquer(terminerProjet(projetCourant));
});
ecarter.addEventListener('click', () => {
if (!projetCourant) return;
const nom = etatProjet(projetCourant);
appliquer(nom === 'discarded' ? reanimerProjet(projetCourant) : ecarterProjet(projetCourant));
});
supprimer.addEventListener('click', async () => {
if (!idCourant) return;
const cible = idCourant;
@@ -222,6 +388,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
peindrePalette(projet.color);
peindreSuggestions();
// L'état a pu changer sans passer par ce panneau : ajouter la première
// phase engage le projet, et le bloc « Horizon » doit disparaître au même
// instant que la barre pâle sur la frise.
peindreCycleDeVie(projet);
},
fermer() {

View File

@@ -12,20 +12,27 @@
*/
import {
LIBELLES_STATUT,
LIBELLES_ETAT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
aujourdhui,
bornesHorizon,
bornesProjet,
bornesReference,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
etatProjet,
formaterDateLongue,
formaterHorizon,
formulerDerive,
libelleStatut,
lundiDeLaSemaine,
phasesDecalables,
projetADater,
projetFiltre,
projetFiltreEtat,
semaineISO,
versUTC,
} from './model.js';
@@ -85,19 +92,28 @@ export function creerEchelle(fenetre) {
/**
* Reconstruit toute la frise.
*
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* Trois tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
* que ce projet existe, mais il encombre ma frise ».
* répond à « je ne regarde que les projets clients ». Le **filtre par état** en
* fait autant, mais en élargissant à chaque case cochée plutôt qu'en resserrant,
* un projet n'ayant qu'un état : il répond à « je ne veux pas voir le
* cimetière ». L'**œil** (`hidden`) garde la ligne de libellé, grisée, et n'ôte
* que les barres : il répond à « je sais que ce projet existe, mais il encombre
* ma frise ».
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant
* @param {object} options { fenetre, curseur, tagsActifs }
* @param {object} options { fenetre, curseur, tagsActifs, etatsActifs }
*/
export function rendre(refs, planning, { fenetre, curseur, tagsActifs = [] }) {
export function rendre(
refs,
planning,
{ fenetre, curseur, tagsActifs = [], etatsActifs = [] }
) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const retenus = planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
const visibles = retenus.filter((projet) => !projet.hidden);
refs.entete.style.width = `${echelle.largeur}px`;
@@ -292,7 +308,7 @@ function construireLibelles(projets, tagsActifs, curseur) {
const ligne = document.createElement('div');
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
ligne.textContent = phase.name;
ligne.title = `${phase.name}${LIBELLES_STATUT[phase.status]}`;
ligne.title = `${phase.name}${libelleStatut(phase)}`;
ligne.dataset.projet = projet.id;
ligne.dataset.phase = phase.id;
elements.push(ligne);
@@ -303,6 +319,8 @@ function construireLibelles(projets, tagsActifs, curseur) {
}
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
const etat = etatProjet(projet);
const ligne = document.createElement('div');
ligne.className =
'libelle-projet' +
@@ -310,6 +328,10 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
(sousCurseur ? ' libelle-projet--curseur' : '');
ligne.style.setProperty('--couleur-projet', projet.color);
ligne.dataset.projet = projet.id;
// L'état se peint en CSS plutôt qu'en classes empilées : il est exclusif, et
// un attribut qui ne peut porter qu'une valeur le dit mieux que quatre
// modificateurs dont un seul doit être posé à la fois.
ligne.dataset.etat = etat;
const plier = document.createElement('button');
plier.className = 'plier';
@@ -333,6 +355,7 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
// 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) +
`\n${LIBELLES_ETAT[etat]}${legendeEtat(projet, etat)}` +
// 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.
@@ -345,6 +368,20 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
// Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu — le
// dit sur sa ligne. C'est le garde-fou qui empêche les envisagés de devenir
// une décharge : on ne peut pas ne pas le voir en parcourant la colonne, et
// il ne s'éteint qu'en repoussant l'horizon ou en écartant le projet.
if (projetADater(projet)) {
const alerte = document.createElement('span');
alerte.className = 'libelle-projet__adater';
alerte.textContent = 'à dater';
alerte.title = projet.horizon
? `Horizon dépassé (${formaterHorizon(projet.horizon)}) — repousser ou écarter.`
: "Aucun horizon : ce projet n'est situé nulle part sur la frise.";
ligne.append(alerte);
}
const derive = formulerDerive(deriveProjet(projet));
if (derive) {
const etiquette = document.createElement('span');
@@ -395,6 +432,19 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
return ligne;
}
/**
* Ce qui suit le nom de l'état dans une infobulle : l'horizon d'un projet
* envisagé, la date de l'acte pour les deux qu'on prononce.
*/
function legendeEtat(projet, etat) {
if (etat === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (etat === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
if (etat === 'discarded') return ` le ${formaterDateLongue(projet.discardedDate)}`;
return '';
}
// ---------------------------------------------------------------------------
// Couloirs
// ---------------------------------------------------------------------------
@@ -411,12 +461,16 @@ function construireCouloirs(projets, echelle, curseur) {
const couloirProjet = document.createElement('div');
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
couloirProjet.style.setProperty('--couleur-projet', projet.color);
couloirProjet.dataset.etat = etatProjet(projet);
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
const barre = construireBarreCumulative(projet, echelle);
//
// Un projet envisagé n'a pas de phase à cumuler : c'est son horizon qui
// occupe la ligne, et rien d'autre ne s'affiche sous elle.
const barre = construireBarreCumulative(projet, echelle) ?? construireHorizon(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
@@ -455,7 +509,12 @@ function construirePhase(projet, phase, echelle, curseur) {
if (phase.milestone) {
const losange = document.createElement('div');
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
losange.classList.add('jalon', `jalon--${phase.status}`);
// Une date imposée se lit sur le losange, et pas seulement dans le panneau :
// un planning se parcourt des yeux, et savoir ce qui suivra un décalage doit
// se voir avant de faire le geste, pas après.
if (phase.fixed) losange.classList.add('jalon--imposee');
if (selectionnee) losange.classList.add('jalon--selectionne');
losange.style.left = `${echelle.x(phase.start)}px`;
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
@@ -465,7 +524,7 @@ function construirePhase(projet, phase, echelle, curseur) {
// infobulle les taisait, alors que celle d'une barre les montrait.
losange.title =
`${phase.name}${formaterDateLongue(phase.start)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
`\n${libelleStatut(phase)}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
elements.push(losange);
@@ -473,13 +532,22 @@ function construirePhase(projet, phase, echelle, curseur) {
nom.className = 'jalon-nom';
nom.style.left = `${echelle.x(phase.start)}px`;
nom.textContent = phase.name;
// Le nom vit hors du losange — il déborderait de ses 12 pixels. Il porte donc
// sa propre identité, faute de quoi le décalage d'un projet emporterait le
// jalon en laissant son nom sur place.
nom.dataset.projet = projet.id;
nom.dataset.phase = phase.id;
elements.push(nom);
return elements;
}
const barre = document.createElement('div');
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`;
barre.classList.add('barre', `barre--${phase.status}`);
// Voir le losange plus haut : c'est la même marque, un cadre tireté qui cerne
// la forme — laquelle, le CSS s'en arrange.
if (phase.fixed) barre.classList.add('barre--imposee');
if (selectionnee) barre.classList.add('barre--selectionnee');
barre.style.left = `${echelle.x(phase.start)}px`;
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
barre.dataset.projet = projet.id;
@@ -487,7 +555,7 @@ function construirePhase(projet, phase, echelle, curseur) {
barre.dataset.role = 'barre';
barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
`\n${libelleStatut(phase)}` +
// 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
@@ -518,40 +586,143 @@ function construirePhase(projet, phase, echelle, curseur) {
/**
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
*
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
* Affichée qu'il soit plié ou déplié. Elle se saisit pour repousser le projet
* entier, à condition qu'il reste quelque chose à repousser : un projet dont
* toutes les phases sont terminées n'a rien qui bouge, et sa barre reste inerte
* plutôt que d'offrir une prise qui ne mènerait à rien (docs/decisions.md,
* section 8).
*/
function construireBarreCumulative(projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return null;
const decalables = phasesDecalables(projet);
const conteneur = document.createElement('div');
conteneur.className = 'barre-cumulative';
conteneur.style.left = `${echelle.x(bornes.start)}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
conteneur.dataset.projet = projet.id;
// Rôle distinct de `barre` et de `horizon` : drag.js y reconnaît qu'il décale
// un projet entier, et non l'objet unique qu'il a sous le pointeur.
if (decalables.length) conteneur.dataset.role = 'cumul';
const derive = formulerDerive(deriveProjet(projet));
conteneur.title =
`${projet.name}\n${formaterDateLongue(bornes.start)}${formaterDateLongue(bornes.end)}` +
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
(derive ? `\nÉcart à la référence : ${derive}` : '') +
// Déplié, les barres modifiables sont juste en dessous : le dire serait
// enfoncer une porte ouverte.
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
`\n\n${modeDEmploiDecalage(projet, decalables.length)}`;
const origine = echelle.x(bornes.start);
for (const phase of projet.phases) {
const segment = document.createElement('div');
segment.className = `segment segment--${phase.status}`;
// Le geste rejoue la géométrie sur ces mêmes nœuds à chaque mouvement de
// souris, et le décalage retrie les phases : c'est l'identifiant qui les
// apparie, jamais leur rang.
segment.dataset.phase = phase.id;
conteneur.append(segment);
}
poserBarreCumulative(conteneur, projet, echelle);
return conteneur;
}
/**
* Ce que dit l'infobulle du résumé sur le geste qu'il offre.
*
* La restriction aux phases non terminées ne s'annonce que lorsqu'elle joue :
* sur un projet dont rien n'est clos — le cas courant — le geste emporte tout,
* et il n'y a aucune nuance à apprendre.
*/
function modeDEmploiDecalage(projet, nombreDecalables) {
if (!nombreDecalables) {
return 'Rien à repousser : ses phases sont toutes terminées ou à date imposée.';
}
if (nombreDecalables === projet.phases.length) return 'Glisser pour repousser le projet.';
const retenues = projet.phases.length - nombreDecalables;
const compte =
retenues > 1 ? `${retenues} de ses phases ne suivront pas` : 'Une de ses phases ne suivra pas';
return `Glisser pour repousser le projet. ${compte} — terminée${retenues > 1 ? 's' : ''} ou à date imposée.`;
}
/**
* Pose la géométrie de la barre cumulative et de ses segments d'après l'état du
* projet.
*
* Séparée de sa construction parce que `drag.js` la rejoue à chaque mouvement de
* souris : décaler un projet dont une partie est terminée ne translate pas cette
* barre, il la déforme — le bord gauche tient, le reste s'en va. Elle ne peut
* donc pas se contenter d'un `left` mis à jour, et la formule doit vivre au même
* endroit que celle du rendu, sous peine de voir l'aperçu et le résultat
* diverger.
*/
export function poserBarreCumulative(conteneur, projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return;
const origine = echelle.x(bornes.start);
conteneur.style.left = `${origine}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
const segments = new Map();
for (const segment of conteneur.children) segments.set(segment.dataset.phase, segment);
for (const phase of projet.phases) {
const segment = segments.get(phase.id);
if (!segment) continue;
segment.style.left = `${echelle.x(phase.start) - origine}px`;
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
segment.style.width = phase.milestone
? '3px'
: `${echelle.largeurDe(phase.start, phase.end)}px`;
conteneur.append(segment);
}
}
/**
* Barre d'horizon d'un projet envisagé : pâle, aux bords fondus.
*
* Elle se glisse et se redimensionne comme une barre de phase, mais **accrochée
* au mois** là où celle d'une phase l'est au lundi. C'est le même parti pris
* qu'à la décision 9, poussé d'un cran : on ne pose pas un horizon au jour près,
* donc le geste ne le permet pas — tandis qu'interdire le glisser tout court, ce
* qui fut d'abord essayé, obligeait à passer par le panneau pour un ajustement
* qu'on veut faire en regardant les voisins.
*
* Sa largeur *est* l'incertitude : un projet vague occupe une large plage floue,
* un projet mûr un seul mois. Rien à écrire pour le dire, l'œil le lit.
*/
function construireHorizon(projet, echelle) {
const bornes = bornesHorizon(projet);
if (!bornes) return null;
const barre = document.createElement('div');
barre.className = 'barre-horizon';
barre.style.left = `${echelle.x(bornes.start)}px`;
barre.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
barre.dataset.projet = projet.id;
// Un rôle distinct de celui d'une phase : `drag.js` y reconnaît qu'il déplace
// des mois et non des jours, et qu'il n'y a pas de phase à aller chercher.
barre.dataset.role = 'horizon';
if (projetADater(projet)) barre.classList.add('barre-horizon--depasse');
barre.title =
`${projet.name}\nEnvisagé — ${formaterHorizon(projet.horizon)}` +
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
'\n\nSe glisse au mois. Ajouter une phase engage le projet et lui donne des dates fermes.';
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = projet.name;
barre.append(nom);
for (const bord of ['debut', 'fin']) {
const poignee = document.createElement('div');
poignee.className = `poignee poignee--${bord}`;
poignee.dataset.role = 'poignee';
poignee.dataset.bord = bord;
barre.append(poignee);
}
return conteneur;
return barre;
}
/** Barre fantôme du projet, reflétant l'étendue de référence. */

View File

@@ -67,7 +67,7 @@ const portsTentes = 10
// 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": []}`)
var donneesInitiales = []byte(`{"version": 6, "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 4 (js/model.js, VERSION_FORMAT).
if planning.Version != 4 {
t.Errorf("version = %d, attendu 4", planning.Version)
// Le format courant est la version 6 (js/model.js, VERSION_FORMAT).
if planning.Version != 6 {
t.Errorf("version = %d, attendu 6", planning.Version)
}
if len(planning.Projects) != 0 {
t.Errorf("projects = %v, attendu vide", planning.Projects)

View File

@@ -6,37 +6,60 @@ import {
MAX_LONGUEUR_TAG,
VERSION_FORMAT,
ajouterJours,
ajouterMois,
ajouterPhase,
bornesHorizon,
bornesPlanning,
bornesProjet,
bornesReference,
ancreDecalage,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
dateValide,
decalerHorizon,
decalerProjet,
definirHorizon,
deplacerPhase,
dernierJourDuMois,
dimancheDeLaSemaine,
deriveProjet,
duree,
ecarterProjet,
ecartJours,
ecartMois,
empriseProjet,
entreesParMois,
etatProjet,
fabriquerId,
figerReference,
formaterDateLongue,
formaterHorizon,
formaterMois,
formaterPeriode,
formulerDerive,
libelleStatut,
libererReference,
lundiDeLaSemaine,
modifierPhase,
moisValide,
normaliserTags,
phasesParMois,
phasesDecalables,
planningVide,
pourEcriture,
premierJourDuMois,
projetADater,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
redimensionnerPhase,
remplacerPhase,
rouvrirProjet,
semaineISO,
supprimerPhase,
teinteTag,
terminerProjet,
tousLesTags,
validerPlanning,
} from '../js/model.js';
@@ -164,6 +187,57 @@ describe('dates', () => {
});
});
// --- mois -------------------------------------------------------------------
describe('mois', () => {
test('moisValide accepte « AAAA-MM » et rejette le reste', () => {
assert.ok(moisValide('2027-03'));
assert.ok(moisValide('2027-12'));
assert.ok(!moisValide('2027-13'), 'mois inexistant');
assert.ok(!moisValide('2027-00'), 'mois zéro');
assert.ok(!moisValide('2027-3'), 'sans zéro de remplissage');
assert.ok(!moisValide('2027-03-01'), 'une date n est pas un mois');
assert.ok(!moisValide(''));
assert.ok(!moisValide(null));
assert.ok(!moisValide(202703));
});
test('les bornes d un mois encadrent ses jours, bissextiles comprises', () => {
assert.equal(premierJourDuMois('2027-03'), '2027-03-01');
assert.equal(dernierJourDuMois('2027-03'), '2027-03-31');
assert.equal(dernierJourDuMois('2027-04'), '2027-04-30');
assert.equal(dernierJourDuMois('2028-02'), '2028-02-29', 'année bissextile');
assert.equal(dernierJourDuMois('2027-02'), '2027-02-28');
assert.equal(dernierJourDuMois('2026-12'), '2026-12-31', 'report d année');
});
test('ajouterMois traverse les années dans les deux sens', () => {
assert.equal(ajouterMois('2026-11', 3), '2027-02');
assert.equal(ajouterMois('2026-02', -3), '2025-11');
assert.equal(ajouterMois('2026-12', 1), '2027-01');
assert.equal(ajouterMois('2026-01', -1), '2025-12');
assert.equal(ajouterMois('2026-08', 0), '2026-08');
assert.equal(ajouterMois('2026-08', 24), '2028-08');
});
test('ecartMois est signé et vaut zéro entre deux mois égaux', () => {
assert.equal(ecartMois('2026-11', '2027-02'), 3);
assert.equal(ecartMois('2027-02', '2026-11'), -3);
assert.equal(ecartMois('2026-08', '2026-08'), 0);
});
test('formaterHorizon n écrit l année qu une fois quand elle est partagée', () => {
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-03' }), 'mars 2027');
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-06' }), 'mars → juin 2027');
assert.equal(
formaterHorizon({ start: '2026-11', end: '2027-02' }),
'novembre 2026 → février 2027'
);
assert.equal(formaterHorizon(undefined), '');
});
});
// --- identifiants -----------------------------------------------------------
describe('fabriquerId', () => {
@@ -298,6 +372,24 @@ describe('validerPlanning', () => {
assert.equal(resultat.projects[0].notes, '');
});
test('une phase d un fichier en version 5 n a pas de date imposée', () => {
// Rien dans les données ne permettrait de deviner laquelle l'était : la
// migration ne devine pas, elle met tout à `false`.
const resultat = validerPlanning({ version: 5, projects: [projet({ phases: [phase()] })] });
assert.equal(resultat.projects[0].phases[0].fixed, false);
});
test('conserve une date imposée, et l écrit dans le fichier', () => {
const resultat = validerPlanning({
projects: [projet({ phases: [phase({ fixed: true })] })],
});
assert.equal(resultat.projects[0].phases[0].fixed, true);
const relu = JSON.parse(JSON.stringify(pourEcriture(resultat)));
assert.equal(relu.version, VERSION_FORMAT);
assert.equal(relu.projects[0].phases[0].fixed, true);
});
test('conserve les notes d un projet', () => {
const resultat = validerPlanning({
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
@@ -458,7 +550,7 @@ describe('bornes', () => {
// --- regroupement par mois --------------------------------------------------
describe('phasesParMois', () => {
describe('entreesParMois', () => {
const planning = {
version: VERSION_FORMAT,
projects: [
@@ -493,7 +585,7 @@ describe('phasesParMois', () => {
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
test('rattache chaque phase au seul mois de son début', () => {
const groupes = phasesParMois(planning);
const groupes = entreesParMois(planning);
assert.deepEqual(cles(groupes), ['2026-09', '2026-11']);
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
// « Rédaction » traverse octobre et novembre sans y réapparaître.
@@ -501,8 +593,8 @@ describe('phasesParMois', () => {
});
test('ne fabrique pas de mois vide entre deux mois pleins', () => {
assert.equal(phasesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
assert.equal(cles(phasesParMois(planning)).includes('2026-10'), false);
assert.equal(entreesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
assert.equal(cles(entreesParMois(planning)).includes('2026-10'), false);
});
test('trie les mois du plus ancien au plus récent, à cheval sur les années', () => {
@@ -516,7 +608,7 @@ describe('phasesParMois', () => {
}),
],
};
assert.deepEqual(cles(phasesParMois(surDeuxAns)), ['2026-12', '2027-01']);
assert.deepEqual(cles(entreesParMois(surDeuxAns)), ['2026-12', '2027-01']);
});
test('trie un mois par date, puis par projet, puis par nom de phase', () => {
@@ -537,24 +629,98 @@ describe('phasesParMois', () => {
}),
],
};
assert.deepEqual(noms(phasesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
assert.deepEqual(noms(entreesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
});
test('applique le filtre par tags, et écarte les projets masqués', () => {
assert.deepEqual(cles(phasesParMois(planning, ['interne'])), ['2026-09']);
assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']);
assert.deepEqual(cles(entreesParMois(planning, ['interne'])), ['2026-09']);
assert.deepEqual(noms(entreesParMois(planning, ['interne'])[0]), ['Rédaction']);
const masque = {
projects: planning.projects.map((p) =>
p.id === 'portail' ? { ...p, hidden: true } : p
),
};
assert.deepEqual(noms(phasesParMois(masque)[0]), ['Cadrage']);
assert.deepEqual(noms(entreesParMois(masque)[0]), ['Cadrage']);
});
test('rend une liste vide plutôt que null quand rien ne reste', () => {
assert.deepEqual(phasesParMois(planningVide()), []);
assert.deepEqual(phasesParMois(planning, ['inconnu']), []);
assert.deepEqual(entreesParMois(planningVide()), []);
assert.deepEqual(entreesParMois(planning, ['inconnu']), []);
});
test('un projet envisagé paraît au premier mois de son horizon', () => {
// Il n'a aucune phase : sans cette entrée, un projet visé pour mars 2027
// n'apparaîtrait nulle part dans une liste qui répond pourtant à « qu'est-ce
// qui arrive, et quand ».
const avecEnvisage = {
projects: [projet({ id: 'idee', name: 'Idée', horizon: { start: '2027-03', end: '2027-06' } })],
};
const groupes = entreesParMois(avecEnvisage);
assert.deepEqual(cles(groupes), ['2027-03'], "au début de l'horizon, pas à sa fin");
assert.deepEqual(groupes[0].entrees[0].horizon, { start: '2027-03', end: '2027-06' });
assert.equal(groupes[0].entrees[0].phase, undefined);
});
test('un projet envisagé sans horizon ne paraît nulle part', () => {
// Il n'a rien à quoi se rattacher, et sa pastille « à dater » le signale
// déjà dans la colonne de la frise.
assert.deepEqual(entreesParMois({ projects: [projet({ id: 'flottant' })] }), []);
});
test('un projet écarté avant d avoir été engagé paraît à son horizon', () => {
// C'est l'absence de phases qui compte, pas l'état : sans cela, ouvrir le
// cimetière dans la liste ne montrerait que les projets qui avaient démarré.
const cimetiere = {
projects: [
projet({
id: 'abandonne',
name: 'Abandonné',
horizon: { start: '2027-03', end: '2027-03' },
discardedDate: '2026-09-14',
}),
],
};
const groupes = entreesParMois(cimetiere, [], ['discarded']);
assert.deepEqual(cles(groupes), ['2027-03']);
assert.equal(etatProjet(groupes[0].entrees[0].projet), 'discarded');
// Et il reste hors de vue tant qu'on n'a pas coché son état.
assert.deepEqual(entreesParMois(cimetiere, [], ['considered', 'engaged']), []);
});
test('un projet engagé ne paraît pas deux fois malgré son horizon conservé', () => {
// L'horizon survit à l'engagement, mais ce sont les phases qui parlent
// désormais : le lister en plus ferait un doublon.
const engage = {
projects: [
projet({
id: 'engage',
horizon: { start: '2027-03', end: '2027-06' },
phases: [phase({ start: '2026-08-01', end: '2026-08-20' })],
}),
],
};
const groupes = entreesParMois(engage);
assert.deepEqual(cles(groupes), ['2026-08']);
assert.equal(groupes[0].entrees.length, 1);
});
test('un envisagé ouvre son mois, devant les phases qui y démarrent plus tard', () => {
const melange = {
projects: [
projet({ id: 'idee', name: 'Idée', horizon: { start: '2026-09', end: '2026-09' } }),
projet({
id: 'autre',
name: 'Autre',
phases: [phase({ id: 'p', name: 'Tâche', start: '2026-09-15', end: '2026-09-20' })],
}),
],
};
assert.deepEqual(
entreesParMois(melange)[0].entrees.map((e) => e.projet.id),
['idee', 'autre']
);
});
});
@@ -756,3 +922,458 @@ describe('modifications', () => {
});
});
});
// --- décalage d'un projet ---------------------------------------------------
// --- libellé d'une phase ----------------------------------------------------
describe('libelleStatut', () => {
test('rend le statut seul quand la date nous appartient', () => {
assert.equal(libelleStatut(phase({ status: 'doing' })), 'En cours');
});
test('accole la date imposée au statut, sans le remplacer', () => {
const libelle = libelleStatut(phase({ status: 'blocked', fixed: true }));
assert.match(libelle, /^Bloqué — date imposée/);
});
// Le validateur ramène un statut inconnu à « à venir » plutôt que de refuser
// le fichier : le libellé fait le même choix, faute de quoi une vue afficherait
// « undefined » là où la frise dessine une barre.
test('retombe sur « à venir » pour un statut inconnu', () => {
assert.equal(libelleStatut(phase({ status: 'zzz' })), 'À venir');
});
});
describe('décaler un projet', () => {
const enCours = projet({
phases: [
phase({ id: 'cadrage', start: '2026-08-03', end: '2026-08-21', status: 'done' }),
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'livraison',
start: '2026-10-12',
end: '2026-10-12',
status: 'todo',
milestone: true,
}),
],
});
const avecImposee = projet({
phases: [
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'audit-externe',
start: '2026-09-07',
end: '2026-09-18',
status: 'todo',
fixed: true,
}),
],
});
test('emporte tout ce qui n est pas terminé, et rien d autre', () => {
assert.deepEqual(
phasesDecalables(enCours).map((p) => p.id),
['chantier', 'livraison']
);
});
test('n emporte pas une phase à date imposée, quel que soit son statut', () => {
assert.deepEqual(
phasesDecalables(avecImposee).map((p) => p.id),
['chantier']
);
// Le statut ne rachète rien : c'est bien la date qui est tenue, pas le
// travail qui serait fini.
const enCoursEtImposee = projet({
phases: [phase({ status: 'doing', fixed: true })],
});
assert.deepEqual(phasesDecalables(enCoursEtImposee), []);
});
test('une phase à date imposée reste en place quand le projet est repoussé', () => {
const repousse = decalerProjet(avecImposee, 30);
const audit = repousse.phases.find((p) => p.id === 'audit-externe');
const chantier = repousse.phases.find((p) => p.id === 'chantier');
assert.deepEqual(
{ start: audit.start, end: audit.end },
{ start: '2026-09-07', end: '2026-09-18' }
);
assert.equal(chantier.start, '2026-09-23');
// Le chantier passe désormais par-dessus l'audit : c'est l'information que
// le geste doit faire apparaître, pas un cas à empêcher.
assert.ok(ecartJours(chantier.start, audit.end) < 0);
});
test('un projet dont tout est imposé ou terminé n offre aucune prise', () => {
const tenu = projet({
phases: [
phase({ id: 'a', status: 'done' }),
phase({ id: 'b', start: '2026-09-01', end: '2026-09-10', fixed: true }),
],
});
assert.equal(ancreDecalage(tenu), null);
assert.deepEqual(decalerProjet(tenu, 10).phases, tenu.phases);
});
test('s ancre sur la première phase qui bouge, pas sur le début du projet', () => {
// Le projet commence le 3 août, mais ce début est clos : c'est le 24 qui
// suit le pointeur.
assert.equal(ancreDecalage(enCours), '2026-08-24');
assert.equal(ancreDecalage(projet({ phases: [phase()] })), '2026-08-01');
});
test('un projet dont tout est terminé n a pas d ancre', () => {
const clos = projet({ phases: [phase({ status: 'done' })] });
assert.equal(ancreDecalage(clos), null);
assert.equal(ancreDecalage(projet()), null, 'ni un projet sans phase');
});
test('applique le même delta partout : les écarts sont conservés au jour près', () => {
const repousse = decalerProjet(enCours, 14);
const [cadrage, chantier, livraison] = repousse.phases;
assert.deepEqual(
{ start: cadrage.start, end: cadrage.end },
{ start: '2026-08-03', end: '2026-08-21' },
'la phase terminée reste où elle est'
);
assert.deepEqual(
{ start: chantier.start, end: chantier.end },
{ start: '2026-09-07', end: '2026-10-16' }
);
assert.deepEqual(
{ start: livraison.start, end: livraison.end },
{ start: '2026-10-26', end: '2026-10-26' },
'le jalon suit, et tient toujours sur un jour'
);
assert.equal(
ecartJours(chantier.end, livraison.start),
ecartJours(enCours.phases[1].end, enCours.phases[2].start)
);
});
test('recule aussi bien qu il avance', () => {
const recule = decalerProjet(enCours, -7);
assert.equal(recule.phases[1].start, '2026-08-17');
// Rien n'interdit de chevaucher une phase terminée : il n'y a pas de
// dépendances entre phases, et une butée en inventerait une.
assert.ok(ecartJours(recule.phases[1].start, recule.phases[0].end) > 0);
});
test('un décalage nul rend le projet inchangé', () => {
assert.equal(decalerProjet(enCours, 0), enCours);
});
test('la référence figée ne suit pas : c est elle qui mesure la dérive', () => {
const fige = figerReference(enCours, '2026-08-01');
const repousse = decalerProjet(fige, 21);
assert.deepEqual(repousse.phases[1].baseline, { start: '2026-08-24', end: '2026-10-02' });
assert.equal(deriveProjet(repousse), 21);
});
test('retrie les phases quand le décalage change leur ordre', () => {
const desordonne = projet({
phases: [
phase({ id: 'a', start: '2026-08-01', end: '2026-08-05', status: 'todo' }),
phase({ id: 'b', start: '2026-08-10', end: '2026-08-15', status: 'done' }),
],
});
assert.deepEqual(
decalerProjet(desordonne, 30).phases.map((p) => p.id),
['b', 'a']
);
});
});
// --- cycle de vie -----------------------------------------------------------
describe('cycle de vie', () => {
const envisage = projet({ horizon: { start: '2027-03', end: '2027-06' } });
const engage = projet({ horizon: { start: '2027-03', end: '2027-06' }, phases: [phase()] });
test('l état se déduit des phases tant que rien n a été prononcé', () => {
assert.equal(etatProjet(envisage), 'considered');
assert.equal(etatProjet(projet()), 'considered', 'même sans horizon');
assert.equal(etatProjet(engage), 'engaged');
});
test('terminer et écarter sont des actes, et ils horodatent', () => {
const clos = terminerProjet(engage, '2027-11-08');
assert.equal(etatProjet(clos), 'completed');
assert.equal(clos.completedDate, '2027-11-08');
const ecarte = ecarterProjet(envisage, '2026-09-14');
assert.equal(etatProjet(ecarte), 'discarded');
assert.equal(ecarte.discardedDate, '2026-09-14');
});
test('clore le projet termine toutes ses phases, jalons compris', () => {
// Sans cela la frise se contredirait : une ligne éteinte au-dessus de
// segments pâles, et des tâches « à venir » listées dans un projet fini.
const enCours = projet({
phases: [
phase({ id: 'a', name: 'A', status: 'done' }),
phase({ id: 'b', name: 'B', status: 'blocked' }),
phase({ id: 'c', name: 'C', status: 'todo', milestone: true, end: '2026-08-01' }),
],
});
const clos = terminerProjet(enCours, '2027-11-08');
assert.deepEqual(
clos.phases.map((p) => p.status),
['done', 'done', 'done']
);
assert.equal(clos.phases[2].milestone, true, 'un jalon reste un jalon');
});
test('rouvrir ne rend pas aux phases le statut qu elles avaient', () => {
// L'écrasement est le prix assumé de la clôture, et la raison pour laquelle
// l'interface la fait confirmer.
const enCours = projet({ phases: [phase({ status: 'blocked' })] });
const rouvert = rouvrirProjet(terminerProjet(enCours));
assert.equal(etatProjet(rouvert), 'engaged');
assert.equal(rouvert.phases[0].status, 'done');
});
test('clotureEcraseDesStatuts ne prévient que s il reste quelque chose à écraser', () => {
assert.ok(clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'todo' })] })));
assert.ok(!clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'done' })] })));
assert.ok(!clotureEcraseDesStatuts(projet()), 'aucune phase, rien à écraser');
});
test('des phases toutes finies ne closent pas le projet à elles seules', () => {
// Livré n'est pas clos : c'est quelqu'un qui le prononce.
const livre = projet({ phases: [phase({ status: 'done' })] });
assert.equal(etatProjet(livre), 'engaged');
});
test('un projet ne porte jamais les deux dates à la fois', () => {
const clos = terminerProjet(engage, '2027-11-08');
const puisEcarte = ecarterProjet(clos, '2027-12-01');
assert.equal(puisEcarte.completedDate, undefined);
assert.equal(etatProjet(puisEcarte), 'discarded');
});
test("l'écartement l'emporte sur la clôture dans un fichier retouché à la main", () => {
const incoherent = projet({ completedDate: '2027-01-01', discardedDate: '2027-02-01' });
assert.equal(etatProjet(incoherent), 'discarded');
});
test('réanimer rend au projet l état que ses données commandent', () => {
// Aucune destination à choisir : c'est tout l'intérêt d'un état déduit.
assert.equal(etatProjet(reanimerProjet(ecarterProjet(engage))), 'engaged');
assert.equal(etatProjet(reanimerProjet(ecarterProjet(envisage))), 'considered');
});
test('rouvrir annule la seule clôture, sans retirer ni ajouter de phase', () => {
const rouvert = rouvrirProjet(terminerProjet(engage, '2027-11-08'));
assert.equal(etatProjet(rouvert), 'engaged');
assert.deepEqual(
rouvert.phases.map((p) => p.id),
engage.phases.map((p) => p.id)
);
// Leur statut, lui, a été écrasé par la clôture — voir le test dédié.
});
test('un projet qui perd sa dernière phase redevient envisagé', () => {
// Le seul retour arrière du cycle, et il n'a rien à déclarer.
const vide = supprimerPhase(engage, 'cadrage');
assert.equal(etatProjet(vide), 'considered');
assert.deepEqual(empriseProjet(vide), { start: '2027-03-01', end: '2027-06-30' });
});
test('creerProjet fait naître un projet envisagé, ancré sur le mois courant', () => {
const p = creerProjet(planningVide(), 'Salle des fêtes', [], '2026-08');
assert.equal(etatProjet(p), 'considered');
assert.deepEqual(p.horizon, { start: '2026-08', end: '2026-08' });
});
test('projetFiltreEtat élargit à chaque état coché, contrairement aux tags', () => {
assert.ok(projetFiltreEtat(envisage, ['considered']));
assert.ok(!projetFiltreEtat(envisage, ['engaged']));
assert.ok(projetFiltreEtat(envisage, ['considered', 'engaged']));
assert.ok(projetFiltreEtat(engage, ['considered', 'engaged']));
assert.ok(projetFiltreEtat(envisage, []), 'une liste vide laisse tout passer');
});
test('la vue par mois écarte les projets dont l état n est pas coché', () => {
const planning = {
projects: [
projet({ id: 'vivant', name: 'Vivant', phases: [phase({ start: '2026-08-01' })] }),
projet({
id: 'mort',
name: 'Mort',
discardedDate: '2026-07-01',
phases: [phase({ id: 'p', start: '2026-08-05' })],
}),
],
};
const tout = entreesParMois(planning, [], []);
assert.equal(tout[0].entrees.length, 2);
const sansEcartes = entreesParMois(planning, [], ['considered', 'engaged', 'completed']);
assert.deepEqual(
sansEcartes[0].entrees.map((e) => e.projet.id),
['vivant']
);
});
});
// --- horizon ----------------------------------------------------------------
describe('horizon', () => {
const horizon = { start: '2027-03', end: '2027-06' };
test('les bornes d un horizon couvrent ses mois entiers', () => {
assert.deepEqual(bornesHorizon(projet({ horizon })), {
start: '2027-03-01',
end: '2027-06-30',
});
assert.equal(bornesHorizon(projet()), null);
});
test('les phases commandent l emprise, l horizon n est qu un repli', () => {
const avecPhases = projet({ horizon, phases: [phase()] });
assert.deepEqual(empriseProjet(avecPhases), { start: '2026-08-01', end: '2026-08-20' });
assert.deepEqual(empriseProjet(projet({ horizon })), {
start: '2027-03-01',
end: '2027-06-30',
});
assert.equal(empriseProjet(projet()), null, 'ni phase ni horizon');
});
test('definirHorizon remet une fin antérieure au début d aplomb', () => {
// La saisie se fait au fil de la frappe : un intervalle à l'envers est un
// état de passage, pas une erreur à signaler.
assert.deepEqual(definirHorizon(projet(), '2027-06', '2027-03').horizon, {
start: '2027-06',
end: '2027-06',
});
assert.deepEqual(definirHorizon(projet(), '2027-03').horizon, {
start: '2027-03',
end: '2027-03',
});
});
test('decalerHorizon conserve la largeur de l intervalle', () => {
const decale = decalerHorizon(projet({ horizon }), 6);
assert.deepEqual(decale.horizon, { start: '2027-09', end: '2027-12' });
assert.deepEqual(decalerHorizon(projet({ horizon }), -3).horizon, {
start: '2026-12',
end: '2027-03',
});
assert.deepEqual(decalerHorizon(projet(), 6), projet(), 'sans horizon, rien à décaler');
});
test('un horizon dépassé réclame une date, un horizon à venir non', () => {
assert.ok(!projetADater(projet({ horizon }), '2027-04'), 'on est dedans');
assert.ok(!projetADater(projet({ horizon }), '2027-06'), 'dernier mois inclus');
assert.ok(projetADater(projet({ horizon }), '2027-07'), 'dépassé');
});
test('un projet envisagé sans horizon réclame une date lui aussi', () => {
// C'est le cas d'un projet hérité d'un fichier en version 4 : les deux
// manques se signalent pareil.
assert.ok(projetADater(projet(), '2026-08'));
});
test('un projet engagé, clos ou écarté ne réclame jamais de date', () => {
assert.ok(!projetADater(projet({ horizon, phases: [phase()] }), '2030-01'));
assert.ok(!projetADater(projet({ horizon, completedDate: '2027-01-01' }), '2030-01'));
assert.ok(!projetADater(projet({ horizon, discardedDate: '2027-01-01' }), '2030-01'));
});
test('la fenêtre du planning tient compte des horizons', () => {
// Sans cela, un planning fait de projets encore tous envisagés s'ouvrirait
// sur rien.
const planning = { projects: [projet({ horizon })] };
assert.deepEqual(bornesPlanning(planning), { start: '2027-03-01', end: '2027-06-30' });
});
test('la fenêtre du planning ignore les projets écartés', () => {
const planning = {
projects: [
projet({ id: 'vivant', phases: [phase()] }),
projet({
id: 'mort',
discardedDate: '2026-07-01',
phases: [phase({ id: 'p', start: '2019-01-01', end: '2019-06-01' })],
}),
],
};
assert.deepEqual(bornesPlanning(planning), { start: '2026-08-01', end: '2026-08-20' });
});
});
// --- validation du cycle de vie ---------------------------------------------
describe('validerPlanning : horizon et états', () => {
const avec = (champs) => ({ version: 5, projects: [projet(champs)] });
test('accepte un horizon correct et le conserve tel quel', () => {
const relu = validerPlanning(avec({ horizon: { start: '2027-03', end: '2027-06' } }));
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
});
test('laisse sans horizon un projet qui n en a pas', () => {
// C'est le cas de tous les projets d'un fichier en version 4.
const relu = validerPlanning(avec({}));
assert.ok(!('horizon' in relu.projects[0]));
assert.equal(etatProjet(relu.projects[0]), 'considered');
});
test('rejette un horizon mal formé plutôt que de l avaler', () => {
// Contrairement au statut ou aux notes : l'horizon décide d'une position sur
// la frise, et l'avaler laisserait un projet invisible sans raison visible.
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-13', end: '2027-06' } })),
(err) => err instanceof ErreurValidation && /horizon/.test(err.message)
);
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-03-01', end: '2027-06' } })),
ErreurValidation
);
assert.throws(() => validerPlanning(avec({ horizon: 'mars 2027' })), ErreurValidation);
});
test('rejette un horizon dont la fin précède le début, en nommant le fautif', () => {
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-06', end: '2027-03' } })),
(err) => err instanceof ErreurValidation && /« Site web »/.test(err.message)
);
});
test('rejette une date d acte invalide', () => {
assert.throws(
() => validerPlanning(avec({ completedDate: '2027-02-30' })),
(err) => err instanceof ErreurValidation && /completedDate/.test(err.message)
);
assert.throws(
() => validerPlanning(avec({ discardedDate: 'hier' })),
(err) => err instanceof ErreurValidation && /discardedDate/.test(err.message)
);
});
test('un projet du cycle de vie survit à un aller-retour par la validation', () => {
let planning = planningVide();
const p = ecarterProjet(
definirHorizon(creerProjet(planning, 'Salle des fêtes', [], '2026-08'), '2027-03', '2027-06'),
'2026-09-14'
);
planning = { ...planning, projects: [p] };
const relu = validerPlanning(pourEcriture(planning));
assert.deepEqual(relu, validerPlanning(pourEcriture(relu)));
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
assert.equal(relu.projects[0].discardedDate, '2026-09-14');
assert.ok(!('completedDate' in relu.projects[0]), 'les clés undefined ne sont pas écrites');
});
});