Compare commits
7 Commits
3092be86ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 815e144d9a | |||
| 4a513f9a59 | |||
| 55128fd0d4 | |||
| d6f3b2961b | |||
| 60a91ba5b2 | |||
| 8404f12656 | |||
| 3c7cff3ac6 |
55
.gitea/workflows/build.yml
Normal file
55
.gitea/workflows/build.yml
Normal 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
185
README.md
@@ -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 |
|
||||
|
||||
588
css/style.css
588
css/style.css
@@ -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;
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
161
index.html
161
index.html
@@ -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)
|
||||
</label>
|
||||
<!--
|
||||
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">×</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 mois</button>
|
||||
<button type="button" data-cran="3" title="Repousser de trois mois">+3 mois</button>
|
||||
<button type="button" data-cran="12" title="Repousser d'un an">+1 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
261
js/app.js
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
113
js/detail.js
113
js/detail.js
@@ -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;
|
||||
}
|
||||
|
||||
254
js/drag.js
254
js/drag.js
@@ -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');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
|
||||
518
js/model.js
518
js/model.js
@@ -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'incrémenter — le validateur reconstruit chaque projet
|
||||
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
|
||||
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
|
||||
* toutes les notes de projet à la première sauvegarde.
|
||||
* 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 récent* que lui. C'est justement là
|
||||
* qu'est la raison d'incrémenter — le validateur reconstruit chaque projet champ
|
||||
* par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire
|
||||
* antérieur ouvrirait un fichier 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)) {
|
||||
throw new ErreurValidation(
|
||||
`Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).`
|
||||
);
|
||||
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
|
||||
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
|
||||
throw new ErreurValidation(
|
||||
`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
|
||||
* 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.
|
||||
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
|
||||
*
|
||||
* 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.
|
||||
* - 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.
|
||||
* - 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 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.
|
||||
|
||||
132
js/mois.js
132
js/mois.js
@@ -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.
|
||||
|
||||
172
js/projet.js
172
js/projet.js
@@ -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 d’avant 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() {
|
||||
|
||||
219
js/timeline.js
219
js/timeline.js
@@ -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. */
|
||||
|
||||
2
main.go
2
main.go
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user