Ajouter une vue des tâches et jalons groupés par mois

La frise répond mal à « qu'est-ce qui arrive le mois prochain » : la question
traverse les projets, alors que la frise les range précisément en couloirs
séparés. Il fallait balayer une colonne de dates à travers tous les couloirs
en gardant en tête ce qu'on avait vu plus haut — d'où le tableur ressorti à
côté.

Deux boutons dans la barre d'outils basculent vers une liste qui déroule
toutes les phases, projets mêlés, dans l'ordre du calendrier. Chaque ligne se
lit quand, chez qui, puis quoi : période, projet et ses tags, intitulé,
statut.

Une phase est rattachée au seul mois de son début. La répéter dans chaque mois
traversé aurait donné 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. Les mois sans rien ne sont pas représentés : l'absence se lit déjà
dans le saut d'un titre au suivant.

La liste ne s'édite pas, et ce n'est pas une lacune à combler : déplacer une
phase demande de voir ce qu'elle chevauche et ce qu'elle repousse, or c'est
justement ce voisinage que l'ordre chronologique dissout. Seules les pastilles
de tags restent cliquables — filtrer ne change pas les données.

Le filtre par tags vaut pour les deux vues, sans rien changer à sa mécanique,
si bien qu'on ne se demande jamais si l'une montre plus que l'autre. L'œil
(hidden) écarte un projet de la liste comme il écarte ses barres.

La vue choisie n'est pas enregistrée, contrairement à la largeur de la colonne
des libellés : cette largeur est un réglage d'écran, la vue est un geste de
consultation. L'outil s'ouvre donc sur la frise, celle où l'on modifie. Le
défilement horizontal de la frise, lui, est mémorisé le temps de
l'aller-retour, puisque masquer un élément lui fait perdre son scrollLeft.

Deux extractions au passage, chacune parce que deux vues sœurs en ont besoin
et qu'aucune n'a à importer l'autre : les noms de mois et le formatage des
dates rejoignent model.js, les pastilles de tags un module tags.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 05:09:18 +02:00
parent faac28172e
commit 8f8707c461
10 changed files with 971 additions and 84 deletions

View File

@@ -110,6 +110,25 @@ Une fois un projet en place :
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche 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. vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
### La vue par mois
Les deux boutons **Frise** et **Par mois**, en haut à gauche, changent de regard sur les mêmes
données. La vue par mois déroule toutes les tâches et tous les jalons du planning en une seule
liste, projets mêlés, groupés par mois — celui où chaque phase **commence**. C'est la réponse à
« qu'est-ce qui arrive le mois prochain », que la frise donne mal parce qu'elle sépare les projets
en couloirs.
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.
Cette liste se **lit** — elle ne s'édite pas. Déplacer une phase demande de voir ce qu'elle
chevauche, donc la frise ; c'est là que les dates se modifient. Seules les pastilles de tags y
restent cliquables, puisque filtrer ne change rien aux données. Le bouton **Aujourd'hui** ramène ici
sur le mois en cours, et la vue affichée n'est pas mémorisée : l'outil s'ouvre toujours sur la
frise.
### Filtrer par tag ### Filtrer par tag
Un projet peut porter des **tags**`client`, `interne`, `R&D` —, saisis séparés par des virgules Un projet peut porter des **tags**`client`, `interne`, `R&D` —, saisis séparés par des virgules
@@ -119,7 +138,8 @@ existants sont proposés en un clic, et un tag disparaît dès que plus aucun pr
Ils s'affichent en petites pastilles colorées dans la colonne de gauche, et une barre de filtres Ils s'affichent en petites pastilles colorées dans la colonne de gauche, et une barre de filtres
apparaît sous la barre d'outils dès qu'un tag existe. Cliquer un tag — dans la barre de filtres ou apparaît sous la barre d'outils dès qu'un tag existe. Cliquer un tag — dans la barre de filtres ou
directement sur la pastille d'un projet — restreint la frise à ceux qui le portent ; recliquer le directement sur la pastille d'un projet — restreint la frise à ceux qui le portent ; recliquer le
retire. retire. Le filtre vaut pour les **deux vues** : il ne change pas quand on passe de la frise à la
liste par mois.
Cocher plusieurs tags **cumule les conditions** : `client` + `urgent` ne montre que les projets Cocher plusieurs tags **cumule les conditions** : `client` + `urgent` ne montre que les projets
portant les deux. Le filtre n'est pas enregistré, il ne vaut que pour la session en cours. Le bouton portant les deux. Le filtre n'est pas enregistré, il ne vaut que pour la session en cours. Le bouton
@@ -148,7 +168,10 @@ par les champs du panneau de détail.
Il n'y a qu'une seule échelle, donc aucun réglage de zoom à comprendre. Il n'y a qu'une seule échelle, donc aucun réglage de zoom à comprendre.
- **Une frise sans fin** : elle s'élargit d'elle-même quand le défilement approche d'un - **Une frise sans fin** : elle s'élargit d'elle-même quand le défilement approche d'un
bord, ce qui permet d'aller planifier dans un futur qu'aucune phase n'occupe encore. bord, ce qui permet d'aller planifier dans un futur qu'aucune phase n'occupe encore.
- **Des tags sur les projets**, et un filtre qui restreint la frise à ceux qui les portent tous. - **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.
- **Repère « aujourd'hui »** et masquage d'un projet. - **Repère « aujourd'hui »** et masquage d'un projet.
## Ce que l'outil ne fait pas ## Ce que l'outil ne fait pas
@@ -208,7 +231,7 @@ go test ./... # le serveur
``` ```
Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des
identifiants, normalisation et filtrage des tags. Côté serveur : lecture et écriture du planning, identifiants, normalisation et filtrage des tags, regroupement des phases par mois. Côté serveur : lecture et écriture du planning,
refus des corps invalides, rotation des sauvegardes, écriture atomique, résolution du chemin de 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. données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node et à Go suffisent.
@@ -218,9 +241,11 @@ 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 | | `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/` | | `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 | | `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags, regroupement par mois |
| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état | | `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 | | `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes, ancrage sur le mois courant |
| `js/tags.js` | Pastilles de tags, partagées par les deux vues |
| `js/drag.js` | Glisser et redimensionner les barres | | `js/drag.js` | Glisser et redimensionner les barres |
| `js/detail.js` | Panneau de détail d'une phase | | `js/detail.js` | Panneau de détail d'une phase |
| `js/projet.js` | Panneau des paramètres d'un projet : nom, couleur, tags | | `js/projet.js` | Panneau des paramètres d'un projet : nom, couleur, tags |

View File

@@ -34,6 +34,11 @@
--rayon: 6px; --rayon: 6px;
--ombre: 0 1px 2px rgb(15 23 42 / 0.08); --ombre: 0 1px 2px rgb(15 23 42 / 0.08);
/* Seule taille de la vue par mois à descendre sous celle du corps (14 px).
Elle est réservée à ce qui commente la liste — compte d'un mois, repère
« ce mois-ci », pastilles de tags — et jamais à ce qui la compose. */
--taille-annotation: 12px;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -145,6 +150,36 @@ button[aria-pressed="true"] {
color: #fff; color: #fff;
} }
/* --- onglets de vue ------------------------------------------------------- */
/* Deux boutons accolés en un seul bloc, pour qu'ils se lisent comme un choix
entre deux états et non comme deux commandes indépendantes. */
.onglets {
display: flex;
}
.onglets button {
border-radius: 0;
white-space: nowrap;
}
.onglets button:first-child {
border-radius: var(--rayon) 0 0 var(--rayon);
}
.onglets button:last-child {
border-radius: 0 var(--rayon) var(--rayon) 0;
margin-left: -1px;
}
/* La couleur de l'état pressé vient de la règle générale `button[aria-pressed]`.
Ne reste ici que l'empilement : sans lui, la bordure du voisin passerait
par-dessus la sienne à la jointure. */
.onglets button[aria-pressed="true"] {
position: relative;
z-index: 1;
}
/* --- barre de filtres ----------------------------------------------------- */ /* --- barre de filtres ----------------------------------------------------- */
.filtres { .filtres {
@@ -1032,6 +1067,227 @@ button[aria-pressed="true"] {
padding: 0; padding: 0;
} }
/* --- vue par mois -------------------------------------------------------- */
/* Rien n'est positionné en absolu ici, contrairement à la frise : la liste
n'a pas d'échelle temporelle, elle empile des lignes dans l'ordre du
calendrier. Un seul axe de défilement, le vertical.
`position: relative` fait du conteneur l'`offsetParent` de ses groupes, dont
`revelerMoisCourant` (mois.js) lit l'`offsetTop`. */
.mois {
flex: 1;
min-height: 0;
position: relative;
overflow-y: auto;
overflow-x: hidden;
padding: 0 16px 40px;
}
/* Une largeur de lecture plutôt que toute la page : sur un écran large, une
ligne étirée sur 2000 px sépare la date de son statut par un désert. */
.mois-groupe {
max-width: 1080px;
margin: 0 auto;
}
/* Le titre reste visible tant qu'on parcourt son mois : sans lui à l'écran,
une ligne isolée ne dit plus de quel mois elle relève, puisque les dates y
sont abrégées au quantième. */
.mois-groupe__titre {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: baseline;
gap: 10px;
margin: 0;
padding: 18px 2px 7px;
/* Même taille que les lignes qu'il coiffe : c'est la graisse et le filet qui
le détachent, pas un corps plus gros. Déclaré tout de même, sinon la taille
par défaut des `h2` s'appliquerait. */
font-size: 14px;
font-weight: 600;
background: var(--fond);
border-bottom: 1px solid var(--bordure-forte);
}
.mois-groupe__nom {
text-transform: capitalize;
}
/* Les annotations du titre — le repère du mois courant et le compte — partagent
la taille des pastilles de tags : ce sont les seuls éléments de la vue à
descendre sous la taille du corps, parce qu'ils commentent la liste au lieu
d'en faire partie. */
.mois-groupe__courant {
font-size: var(--taille-annotation);
font-weight: 500;
padding: 1px 8px;
border-radius: 9px;
background: color-mix(in srgb, var(--aujourdhui) 16%, var(--surface));
color: var(--aujourdhui);
}
.mois-groupe__compte {
margin-left: auto;
font-size: var(--taille-annotation);
font-weight: 400;
color: var(--texte-doux);
}
.mois-entrees {
list-style: none;
margin: 0;
padding: 0;
}
/* Colonnes fixes d'une ligne à l'autre : c'est l'alignement vertical des dates,
des noms et des statuts qui rend la liste parcourable en diagonale. Les
colonnes de texte sont en `minmax(0, …)` pour que le rognage se fasse à
l'intérieur, sans jamais pousser le statut hors de l'écran.
L'ordre suit celui de la lecture : quand, chez qui, puis quoi. Le ressort
(`1fr`) est sur l'intitulé de la phase, dernière colonne de texte, pour que
l'espace disponible se déverse à sa droite plutôt qu'au milieu de la ligne. */
.mois-entree {
display: grid;
grid-template-columns: 16px 124px minmax(0, 200px) 172px minmax(0, 1fr) 86px;
align-items: center;
gap: 10px;
padding: 6px 2px;
border-bottom: 1px solid var(--bordure);
}
.mois-entree:hover {
background: var(--surface);
}
/* Reprise des formes de la frise : une barre pour une tâche, un losange pour un
jalon, et les mêmes textures de statut — c'est le même objet vu autrement. */
.mois-entree__marque {
width: 14px;
height: 8px;
border-radius: 2px;
background: var(--couleur-projet);
}
.mois-entree--todo .mois-entree__marque {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
border: 1px solid var(--couleur-projet);
}
.mois-entree--done .mois-entree__marque {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
}
.mois-entree--blocked .mois-entree__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
);
}
.mois-entree--jalon .mois-entree__marque {
width: 10px;
height: 10px;
margin-left: 2px;
border-radius: 0;
transform: rotate(45deg);
}
.mois-entree--jalon.mois-entree--blocked .mois-entree__marque {
background: var(--danger);
}
/* 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 {
font-variant-numeric: tabular-nums;
color: var(--texte-doux);
white-space: nowrap;
}
.mois-entree__projet,
.mois-entree__nom {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Un jalon se distingue à la lecture, pas seulement à sa marque : sa date est
un rendez-vous, pas une durée. */
.mois-entree--jalon .mois-entree__nom {
font-weight: 600;
}
/* Le projet passe devant la phase, mais reste en retrait : il situe, il n'est
pas ce qu'on cherche. Le contraste sépare les deux colonnes de texte mieux
qu'un filet ne le ferait. */
.mois-entree__projet {
color: var(--texte-doux);
}
.mois-entree__projet::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: var(--couleur-projet);
vertical-align: baseline;
}
/* Les tags gardent leur colonne même quand le projet n'en porte aucun : c'est
une colonne de grille, de largeur fixe, pas un élément qui flotterait à la
suite du projet. Sans cette fixité, l'intitulé des phases démarrerait à une
abscisse différente selon le nombre de tags de leur projet. */
.mois-entree .tags {
max-width: 100%;
}
/* Les pastilles de la colonne des libellés sont taillées pour une colonne
étroite (10 px) ; ici la place ne manque pas, et une ligne où seuls les tags
seraient minuscules se lirait mal. Elles rejoignent donc la taille des
annotations, comme dans la barre de filtres. */
.mois-entree .tag {
font-size: var(--taille-annotation);
padding: 1px 8px;
border-radius: 10px;
max-width: 100%;
}
.mois-entree__statut {
text-align: right;
color: var(--texte-doux);
white-space: nowrap;
}
.mois-entree__statut[data-statut="doing"] {
color: var(--accent);
font-weight: 500;
}
.mois-entree__statut[data-statut="blocked"] {
color: var(--danger);
font-weight: 500;
}
/* Une phase terminée reste consultable mais recule d'un plan : ce qui compte
dans une liste tournée vers ce qui arrive, c'est ce qui n'est pas fait. */
.mois-entree--done .mois-entree__nom {
color: var(--texte-doux);
}
.mois__vide {
max-width: 1080px;
margin: 48px auto;
text-align: center;
color: var(--texte-doux);
}
/* --- états divers -------------------------------------------------------- */ /* --- états divers -------------------------------------------------------- */
.vide { .vide {

View File

@@ -531,3 +531,50 @@ planning dans git, ce que son format texte indenté rend parfaitement lisible (v
Ce changement a rendu concurrentes deux étapes qui ne l'étaient pas — la copie et le remplacement Ce changement a rendu concurrentes deux étapes qui ne l'étaient pas — la copie et le remplacement
dépendent maintenant d'un état partagé, la date de la dernière sauvegarde. Un verrou les sérialise, dépendent maintenant d'un état partagé, la date de la dernière sauvegarde. Un verrou les sérialise,
ce qui protège au passage du cas où deux onglets enregistrent en même temps. ce qui protège au passage du cas où deux onglets enregistrent en même temps.
## 24. Une vue par mois, en lecture seule, à côté de la frise
La frise répond très bien à « comment ce projet-là s'étale-t-il », et très mal à « qu'est-ce qui
arrive le mois prochain ». La seconde question traverse les projets, alors que la frise les range
précisément en couloirs séparés : il faut balayer une colonne de dates à travers tous les couloirs,
en gardant en tête ce qu'on a vu plus haut. Avec cinq projets et une échelle de six pixels par
jour, c'est déjà pénible ; c'est la raison pour laquelle on ressortait un tableur à côté.
D'où une seconde vue, atteinte par deux boutons dans la barre d'outils. Trois arbitrages la
définissent.
**Une phase est rattachée au mois de son début, et à lui seul.** Une phase de trois mois n'apparaît
donc qu'une fois. La répéter dans chaque mois traversé aurait donné un tableau de charge —
« qu'est-ce qui tourne en octobre » —, qui est une question légitime mais une autre vue : le
doublon coûte la lecture en diagonale qui fait tout l'intérêt de celle-ci, et brouille le compte
affiché en tête de mois. La fin est écrite sur la ligne (« 31 → 13 nov. »), ce qui suffit à voir
qu'une phase déborde et jusqu'où.
**Les mois sans rien ne sont pas représentés.** Un planning à horizon de deux ans a des trous ;
intercaler « novembre 2026 — rien » ferait défiler du vide, alors que l'absence se lit déjà dans le
saut d'un titre au suivant.
**La liste ne s'édite pas.** Aucune poignée, aucun glisser, et un clic sur une ligne n'ouvre pas le
panneau de détail. Ce n'est pas une lacune à combler plus tard : déplacer une phase demande de voir
ce qu'elle chevauche et ce qu'elle repousse, c'est-à-dire le voisinage que la frise montre et que
la liste, ordonnée par date et non par projet, a justement dissous. Une date qu'on modifie sans
voir son contexte est une date qu'on modifie à l'aveugle. La vue sert donc à faire le point, la
frise à arbitrer. Seules les pastilles de tags restent cliquables — filtrer ne change pas les
données, c'est une façon de regarder.
Le filtre par tags, lui, vaut pour les deux vues sans rien changer à sa mécanique : c'est le même
état, la même barre, au même endroit. Un filtre posé sur la frise se retrouve en passant à la
liste, ce qui rend la bascule sûre — on ne se demande jamais si l'une montre plus que l'autre. Les
deux tamis de la frise s'appliquent d'ailleurs à l'identique : l'œil (`hidden`) écarte un projet de
la liste comme il écarte ses barres.
La vue choisie n'est pas enregistrée, contrairement à la largeur de la colonne des libellés
(décision 18). Cette largeur est un réglage d'écran, la vue est un geste de consultation : on passe
par la liste pour faire le point, on revient à la frise pour travailler. L'outil s'ouvre donc
toujours sur la frise, celle où l'on modifie. Le défilement horizontal de la frise, lui, est
mémorisé le temps de l'aller-retour : masquer un élément lui fait perdre son `scrollLeft`, et
revenir des années avant ce qu'on regardait serait une punition pour avoir consulté la liste.
Deux conséquences dans le code, toutes deux de bon aloi : les noms de mois et `formaterDateLongue`
sont remontés de `timeline.js` vers `model.js`, et les pastilles de tags dans `tags.js`. Deux vues
sœurs les emploient, aucune n'a à importer l'autre pour écrire « septembre ».

View File

@@ -14,6 +14,22 @@
<header class="outils"> <header class="outils">
<h1>Frise multi-projets</h1> <h1>Frise multi-projets</h1>
<!--
Deux regards sur les mêmes données : la frise, qui montre les durées et
les chevauchements, et la liste par mois, qui montre l'ordre du
calendrier tous projets mêlés. Le filtre par tags, lui, vaut pour les
deux — d'où sa place inchangée juste en dessous.
-->
<!--
Deux boutons à deux états plutôt que le motif ARIA « tablist » : celui-ci
promet une navigation aux flèches, or les flèches servent déjà à décaler
la phase sélectionnée. Mieux vaut ne rien annoncer qu'annoncer faux.
-->
<div class="onglets" id="onglets" role="group" aria-label="Vue">
<button type="button" data-vue="frise" id="onglet-frise">Frise</button>
<button type="button" data-vue="mois" id="onglet-mois">Par mois</button>
</div>
<div class="outils__groupe"> <div class="outils__groupe">
<button type="button" id="aller-aujourdhui">Aujourd'hui</button> <button type="button" id="aller-aujourdhui">Aujourd'hui</button>
<button type="button" id="nouveau-projet" class="bouton--principal"> <button type="button" id="nouveau-projet" class="bouton--principal">
@@ -63,6 +79,13 @@
</div> </div>
</main> </main>
<!--
Vue par mois. Elle remplace la frise plutôt que de la flanquer : les deux
veulent toute la largeur, l'une pour dérouler le temps, l'autre pour poser
côte à côte la date, le nom, le projet et le statut.
-->
<section class="mois" id="vue-mois" hidden aria-label="Tâches et jalons par mois"></section>
<p class="vide" id="message-vide" hidden> <p class="vide" id="message-vide" hidden>
Aucun projet pour l'instant.<br /> Aucun projet pour l'instant.<br />
<button type="button" id="premier-projet" class="bouton--principal"> <button type="button" id="premier-projet" class="bouton--principal">

View File

@@ -11,7 +11,8 @@ import { creerPanneau } from './detail.js';
import { creerPanneauProjet } from './projet.js'; import { creerPanneauProjet } from './projet.js';
import { creerDialogueConfirmation, creerMenu } from './menu.js'; import { creerDialogueConfirmation, creerMenu } from './menu.js';
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js'; import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js'; import { PIXELS_PAR_JOUR, rendre } from './timeline.js';
import { rendreMois, revelerMoisCourant } from './mois.js';
import { creerStockage, viderAvantFermeture } from './storage.js'; import { creerStockage, viderAvantFermeture } from './storage.js';
import { import {
COULEURS, COULEURS,
@@ -23,6 +24,7 @@ import {
creerPhase, creerPhase,
creerProjet, creerProjet,
figerReference, figerReference,
formaterDateLongue,
libererReference, libererReference,
normaliserTags, normaliserTags,
planningVide, planningVide,
@@ -45,6 +47,8 @@ const refs = {
entete: $('entete'), entete: $('entete'),
couloirs: $('couloirs'), couloirs: $('couloirs'),
frise: $('frise'), frise: $('frise'),
vueMois: $('vue-mois'),
onglets: $('onglets'),
etat: $('etat-sauvegarde'), etat: $('etat-sauvegarde'),
messageVide: $('message-vide'), messageVide: $('message-vide'),
messageFiltre: $('message-filtre'), messageFiltre: $('message-filtre'),
@@ -60,6 +64,27 @@ let planning = planningVide();
let echelle = null; let echelle = null;
let selection = null; // { projet, phase } let selection = null; // { projet, phase }
/**
* Vue affichée : 'frise' ou 'mois'. État de vue, comme le filtre et la fenêtre
* temporelle — jamais enregistré.
*
* Contrairement à la largeur de la colonne, qui est un réglage d'écran, la vue
* choisie est un geste de consultation : on passe par la liste pour faire le
* point, on revient à la frise pour travailler. La retrouver au rechargement
* imposerait à la séance suivante le regard de la précédente ; l'outil s'ouvre
* donc toujours sur la frise, qui est celle où l'on modifie.
*/
let vue = 'frise';
/**
* Défilement horizontal de la frise au moment où on l'a quittée.
*
* Masquer un élément lui fait perdre son `scrollLeft` : sans cette mémoire, un
* aller-retour par la liste ramènerait la frise à son extrême gauche, soit des
* années avant ce qu'on regardait.
*/
let defilementFrise = 0;
/** /**
* Tags sélectionnés dans la barre de filtres. Comme la fenêtre temporelle, * Tags sélectionnés dans la barre de filtres. Comme la fenêtre temporelle,
* c'est un état de **vue** : jamais enregistré, jamais partagé. * c'est un état de **vue** : jamais enregistré, jamais partagé.
@@ -201,15 +226,26 @@ function dessiner() {
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce // Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
// message, la frise vide laisserait croire que les projets ont disparu. // message, la frise vide laisserait croire que les projets ont disparu.
refs.messageFiltre.hidden = !aDesProjets || retenus.length > 0; refs.messageFiltre.hidden = !aDesProjets || retenus.length > 0;
refs.frise.hidden = !aDesProjets || retenus.length === 0;
// Les deux vues partagent le même sort face aux messages : ce qui n'a aucun
// projet à montrer n'en a pas davantage à lister.
const aQuelqueChoseAMontrer = aDesProjets && retenus.length > 0;
refs.frise.hidden = !aQuelqueChoseAMontrer || vue !== 'frise';
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
peindreFiltres(); peindreFiltres();
peindreOnglets();
if (!aDesProjets) { if (!aDesProjets) {
echelle = null; echelle = null;
return; return;
} }
if (vue === 'mois') {
rendreMois(refs.vueMois, planning, { tagsActifs });
return;
}
elargirPourContenir(); elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, selection, tagsActifs }); echelle = rendre(refs, planning, { fenetre, selection, tagsActifs });
@@ -309,6 +345,49 @@ function effacerFiltres() {
refs.effacerFiltres.addEventListener('click', effacerFiltres); refs.effacerFiltres.addEventListener('click', effacerFiltres);
$('effacer-filtres-vide').addEventListener('click', effacerFiltres); $('effacer-filtres-vide').addEventListener('click', effacerFiltres);
// ---------------------------------------------------------------------------
// Bascule entre la frise et la vue par mois
// ---------------------------------------------------------------------------
function peindreOnglets() {
for (const onglet of refs.onglets.querySelectorAll('[data-vue]')) {
onglet.setAttribute('aria-pressed', String(onglet.dataset.vue === vue));
}
}
function basculerVue(suivante) {
if (suivante === vue || !suivante) return;
if (vue === 'frise') defilementFrise = refs.frise.scrollLeft;
vue = suivante;
// Les deux panneaux modifient une phase ou un projet ; la vue par mois se
// lit, elle ne s'édite pas. Les laisser ouverts par-dessus une liste où plus
// rien ne désigne leur objet en ferait des fenêtres orphelines.
panneau.fermer();
panneauProjet.fermer();
dessiner();
// Les deux restaurations sont synchrones : l'élément vient d'être démasqué,
// et lire ou écrire `scroll*` force le calcul de mise en page dont elles ont
// besoin.
if (vue === 'frise') refs.frise.scrollLeft = defilementFrise;
else revelerMoisCourant(refs.vueMois);
}
refs.onglets.addEventListener('click', (evenement) => {
const onglet = evenement.target.closest('[data-vue]');
if (onglet) basculerVue(onglet.dataset.vue);
});
// La vue par mois n'a ni barres ni poignées, mais ses pastilles de tags
// filtrent comme celles de la colonne des libellés.
refs.vueMois.addEventListener('click', (evenement) => {
const pastille = evenement.target.closest('[data-action="tag"]');
if (pastille) basculerTag(pastille.dataset.tag);
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Largeur de la colonne des libellés // Largeur de la colonne des libellés
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -783,6 +862,12 @@ async function supprimerLeProjet(projet) {
* que celle qui sert ensuite à les modifier. * que celle qui sert ensuite à les modifier.
*/ */
function nouveauProjet() { function nouveauProjet() {
// Un projet naît sans phase : il n'apparaîtrait nulle part dans une liste qui
// ne montre que des tâches et des jalons, et le bouton semblerait sans effet.
// On revient donc à la frise, où le nouveau couloir se voit — et où l'on
// pourra lui ajouter ses phases.
basculerVue('frise');
// Un filtre actif préremplit les tags : quand on crée un projet en ne // 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 // regardant que les projets « client », c'en est presque toujours un — et
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant. // sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
@@ -795,7 +880,13 @@ function nouveauProjet() {
// Barre d'outils // Barre d'outils
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
$('aller-aujourdhui').addEventListener('click', () => centrerSur(aujourdhui())); // « Aujourd'hui » veut dire la même chose dans les deux vues — ramène-moi à la
// date du jour — même si le geste diffère : un défilement horizontal sur la
// frise, vertical dans la liste.
$('aller-aujourdhui').addEventListener('click', () => {
if (vue === 'mois') revelerMoisCourant(refs.vueMois);
else centrerSur(aujourdhui());
});
$('nouveau-projet').addEventListener('click', nouveauProjet); $('nouveau-projet').addEventListener('click', nouveauProjet);
$('premier-projet').addEventListener('click', nouveauProjet); $('premier-projet').addEventListener('click', nouveauProjet);

View File

@@ -29,6 +29,21 @@ export const COULEURS = [
'#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16',
]; ];
/**
* Noms de mois, en deux longueurs. Ils vivent ici plutôt que dans une vue :
* la frise et la vue par mois les emploient toutes les deux, et aucune des deux
* n'a à dépendre de l'autre pour écrire « septembre ».
*/
export const MOIS_COURTS = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.',
];
export const MOIS_LONGS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/; const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i; const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
const MS_PAR_JOUR = 86400000; const MS_PAR_JOUR = 86400000;
@@ -123,6 +138,43 @@ export function semaineISO(texte) {
}; };
} }
/** Date « AAAA-MM-JJ » rendue lisible : « 12 sept. 2026 ». */
export function formaterDateLongue(date) {
const [a, m, j] = date.split('-');
return `${j} ${MOIS_COURTS[Number(m) - 1]} ${a}`;
}
/** Mois d'une date, sous la forme « AAAA-MM » — la clé de regroupement. */
export function moisDe(date) {
return date.slice(0, 7);
}
/** Mois « AAAA-MM » rendu lisible : « septembre 2026 ». */
export function formaterMois(mois) {
return `${MOIS_LONGS[Number(mois.slice(5, 7)) - 1]} ${mois.slice(0, 4)}`;
}
/**
* 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
* mois, « 12 → 4 nov. » pour une qui en déborde, « 12 → 4 janv. 2027 » pour une
* qui déborde de l'année. Rien n'est répété tant que rien ne change, ce qui
* fait de l'apparition d'un mois — ou d'une année — le signal du dépassement.
*/
export function formaterPeriode(phase) {
const quantieme = (date) => String(Number(date.slice(8, 10)));
if (phase.milestone) return quantieme(phase.start);
if (phase.end.slice(0, 7) === phase.start.slice(0, 7)) {
return `${quantieme(phase.start)}${quantieme(phase.end)}`;
}
const moisFin = MOIS_COURTS[Number(phase.end.slice(5, 7)) - 1];
const anneeFin =
phase.end.slice(0, 4) === phase.start.slice(0, 4) ? '' : ` ${phase.end.slice(0, 4)}`;
return `${quantieme(phase.start)}${quantieme(phase.end)} ${moisFin}${anneeFin}`;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identifiants // Identifiants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -499,6 +551,58 @@ export function formulerDerive(jours) {
return jours > 0 ? `+${quantite}` : `${quantite}`; return jours > 0 ? `+${quantite}` : `${quantite}`;
} }
// ---------------------------------------------------------------------------
// Regroupement par mois
// ---------------------------------------------------------------------------
/**
* Toutes les phases du planning regroupées 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.
*
* Les deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
* a masqué les barres n'a pas à revenir par la liste.
*
* Les 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}>}>}
* trié du plus ancien au plus récent, chaque mois trié par date puis par nom.
*/
export function phasesParMois(planning, tagsActifs = []) {
const groupes = new Map();
for (const projet of planning.projects) {
if (projet.hidden || !projetFiltre(projet, tagsActifs)) 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 });
}
}
return [...groupes.keys()]
.sort() // « AAAA-MM » se trie comme du texte
.map((mois) => ({
mois,
entrees: groupes.get(mois).sort(
(a, b) =>
versUTC(a.phase.start) - versUTC(b.phase.start) ||
a.projet.name.localeCompare(b.projet.name, 'fr') ||
a.phase.name.localeCompare(b.phase.name, 'fr')
),
}));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Modifications // Modifications
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

220
js/mois.js Normal file
View File

@@ -0,0 +1,220 @@
/**
* Vue par mois : toutes les tâches et tous les jalons du planning en une seule
* liste, regroupés par mois de début, projets mêlés.
*
* C'est la vue transversale que la frise ne peut pas donner. Sur la frise,
* chaque projet a son couloir : savoir ce qui démarre en octobre demande de
* balayer une colonne de dates à travers tous les couloirs. Ici les projets se
* mêlent et l'ordre est celui du calendrier — la question « qu'est-ce qui
* arrive le mois prochain » se lit d'un coup d'œil.
*
* Lecture seule, et sans exception : aucune poignée, aucun glisser, un clic sur
* une ligne n'ouvre rien. La vue sert à faire le point, pas à arbitrer ; les
* dates se modifient là où elles se voient les unes par rapport aux autres,
* c'est-à-dire sur la frise. Seules les pastilles de tags restent cliquables —
* filtrer ne change pas les données, c'est une façon de regarder.
*
* Comme timeline.js, le rendu est complet à chaque appel : on reconstruit le
* DOM depuis l'état plutôt que d'appliquer des différences.
*/
import {
LIBELLES_STATUT,
aujourdhui,
duree,
formaterDateLongue,
formaterMois,
formaterPeriode,
moisDe,
phasesParMois,
projetFiltre,
} from './model.js';
import { pastillesTags } from './tags.js';
/**
* Reconstruit la liste dans son conteneur.
*
* @param {HTMLElement} conteneur
* @param {object} planning
* @param {object} options { tagsActifs }
*/
export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
const groupes = phasesParMois(planning, tagsActifs);
if (!groupes.length) {
conteneur.replaceChildren(messageVide(planning, tagsActifs));
return;
}
const moisCourant = moisDe(aujourdhui());
conteneur.replaceChildren(
...groupes.map((groupe) => construireGroupe(groupe, moisCourant, tagsActifs))
);
}
/**
* Le cas « rien à montrer » n'a pas une seule cause, et elles ne se règlent pas
* de la même façon : des projets encore vides attendent qu'on leur ajoute des
* phases, un filtre trop serré attend qu'on le desserre, des projets masqués
* attendent qu'on rouvre l'œil. Les messages généraux d'app.js ne couvrent
* aucun des trois — ils parlent de projets, quand cette vue ne montre que des
* phases.
*
* 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));
const paragraphe = document.createElement('p');
paragraphe.className = 'mois__vide';
if (retenus.length && retenus.every((projet) => projet.hidden)) {
paragraphe.textContent =
'Tous les projets retenus sont masqués de la frise : leurs tâches ne sont pas listées ' +
'ici non plus. Le menu ⋯ de la frise les rétablit.';
} else if (tagsActifs.length) {
paragraphe.textContent = 'Aucune tâche ni jalon dans les projets retenus par le filtre.';
} else {
paragraphe.textContent = 'Aucune tâche ni jalon : les projets du planning sont encore vides.';
}
return paragraphe;
}
function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
const section = document.createElement('section');
section.className = 'mois-groupe';
section.dataset.mois = mois;
if (mois === moisCourant) section.dataset.courant = 'true';
const titre = document.createElement('h2');
titre.className = 'mois-groupe__titre';
const nom = document.createElement('span');
nom.className = 'mois-groupe__nom';
nom.textContent = formaterMois(mois);
titre.append(nom);
if (mois === moisCourant) {
const marque = document.createElement('span');
marque.className = 'mois-groupe__courant';
marque.textContent = 'ce mois-ci';
titre.append(marque);
}
const compte = document.createElement('span');
compte.className = 'mois-groupe__compte';
compte.textContent = resumerCompte(entrees);
titre.append(compte);
section.append(titre);
const liste = document.createElement('ul');
liste.className = 'mois-entrees';
liste.append(...entrees.map((entree) => construireEntree(entree, tagsActifs)));
section.append(liste);
return section;
}
/** « 4 tâches · 2 jalons », en taisant celui des deux qui vaut zéro. */
function resumerCompte(entrees) {
const jalons = entrees.filter(({ phase }) => phase.milestone).length;
const taches = entrees.length - jalons;
const morceaux = [];
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
if (jalons) morceaux.push(`${jalons} jalon${jalons > 1 ? 's' : ''}`);
return morceaux.join(' · ');
}
function construireEntree({ 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');
ligne.title = infobulle(projet, phase);
// 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.
const marque = document.createElement('span');
marque.className = 'mois-entree__marque';
ligne.append(marque);
const dates = document.createElement('span');
dates.className = 'mois-entree__dates';
dates.textContent = formaterPeriode(phase);
ligne.append(dates);
// Le projet précède la phase : c'est l'ordre dans lequel on nomme les choses
// — « le cadrage du site web », pas l'inverse —, et il donne au regard une
// colonne stable où retrouver la provenance avant de lire l'intitulé.
const nomProjet = document.createElement('span');
nomProjet.className = 'mois-entree__projet';
nomProjet.textContent = projet.name;
ligne.append(nomProjet);
// Les tags suivent le projet, qu'ils qualifient — pas la phase, qui n'en
// porte pas. Toujours posés, même vides : les colonnes de la grille sont
// comptées, et un projet sans tag ferait autrement glisser toute la ligne.
ligne.append(pastillesTags(projet.tags, tagsActifs));
const nom = document.createElement('span');
nom.className = 'mois-entree__nom';
nom.textContent = phase.name;
ligne.append(nom);
const statut = document.createElement('span');
statut.className = 'mois-entree__statut';
statut.dataset.statut = phase.status;
statut.textContent = LIBELLES_STATUT[phase.status];
ligne.append(statut);
return ligne;
}
function infobulle(projet, phase) {
const jours = duree(phase);
return (
`${phase.name}\n${projet.name}\n\n` +
(phase.milestone
? `Jalon du ${formaterDateLongue(phase.start)}`
: `${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
` (${jours} jour${jours > 1 ? 's' : ''})`) +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '')
);
}
/**
* Amène le mois courant en haut de la vue, ou à défaut le premier mois qui le
* suit.
*
* Sans cela, la liste s'ouvre sur le mois le plus ancien du planning — souvent
* des phases terminées depuis des mois. Le repli sur le mois suivant compte :
* un planning peut n'avoir rien du tout au mois courant, et atterrir alors sur
* son passé serait le pire des deux mondes.
*/
export function revelerMoisCourant(conteneur) {
const courant = moisDe(aujourdhui());
const groupes = [...conteneur.querySelectorAll('.mois-groupe')];
const cible = groupes.find((groupe) => groupe.dataset.mois >= courant);
// Aucun mois à venir : tout le planning est derrière nous, on montre sa fin.
if (!cible) {
conteneur.scrollTop = conteneur.scrollHeight;
return;
}
// `offsetTop` est ici relatif au conteneur, qui est son `offsetParent` : la
// règle `position: relative` de `.mois` dans style.css en dépend.
//
// Quand le mois visé tient déjà à l'écran sans défiler — un planning court,
// ou qui commence bientôt —, on ne défile pas. Le navigateur plafonnerait
// sinon le défilement demandé à son maximum, ce qui glisse les premières
// lignes sous le titre collant du mois d'avant sans rien gagner.
const tientDejaAEcran = cible.offsetTop + cible.offsetHeight <= conteneur.clientHeight;
conteneur.scrollTop = tientDejaAEcran ? 0 : cible.offsetTop;
}

71
js/tags.js Normal file
View File

@@ -0,0 +1,71 @@
/**
* Pastilles de tags, partagées par la colonne des libellés (timeline.js) et la
* vue par mois (mois.js).
*
* Elles vivent dans leur propre module parce que deux vues les affichent et
* qu'aucune des deux n'a à importer l'autre pour cela. La barre de filtres
* d'app.js, elle, garde sa propre construction : ses pastilles listent les tags
* du planning entier, pas ceux d'un projet, et ne se résument jamais à un
* compteur.
*/
import { cleTag, teinteTag } from './model.js';
/**
* Au-delà de ce nombre, les tags se résument à un compteur. La colonne des
* libellés est étroite et disputée : montrer quatre pastilles y revenait à les
* rogner toutes jusqu'à l'initiale, ce qui coûtait la place d'un tag sans en
* livrer le nom. Les tags masqués restent lisibles dans l'infobulle, et tous
* sont cliquables depuis la barre de filtres.
*/
const PASTILLES_VISIBLES = 2;
/**
* Chaque pastille est un bouton : cliquer un tag l'ajoute au filtre, le
* recliquer l'en retire. C'est le geste le plus court pour passer d'un projet
* repéré à « montre-moi tous ceux qui lui ressemblent », sans remonter à la
* barre de filtres.
*/
export function pastillesTags(tags, tagsActifs = []) {
const conteneur = document.createElement('span');
conteneur.className = 'tags';
// Deux projets peuvent écrire le même tag différemment (« Client », « client ») :
// c'est la clé, pas la graphie, qui décide s'il est actif.
const clesActives = new Set(tagsActifs.map(cleTag));
// Un tag présent dans le filtre courant passe devant : c'est celui qui
// explique pourquoi le projet est à l'écran, il ne doit pas tomber dans le
// compteur.
const ordonnes = [...tags].sort(
(a, b) => Number(clesActives.has(cleTag(b))) - Number(clesActives.has(cleTag(a)))
);
const montres = ordonnes.slice(0, PASTILLES_VISIBLES);
const restants = ordonnes.slice(PASTILLES_VISIBLES);
for (const tag of montres) {
const pastille = document.createElement('button');
pastille.type = 'button';
pastille.className = 'tag';
pastille.style.setProperty('--teinte-tag', String(teinteTag(tag)));
pastille.textContent = tag;
pastille.dataset.action = 'tag';
pastille.dataset.tag = tag;
const actif = clesActives.has(cleTag(tag));
pastille.setAttribute('aria-pressed', String(actif));
pastille.title = actif ? `Retirer « ${tag} » du filtre` : `Filtrer sur « ${tag} »`;
conteneur.append(pastille);
}
if (restants.length) {
const compteur = document.createElement('span');
compteur.className = 'tag--reste';
compteur.textContent = `+${restants.length}`;
compteur.title = restants.join(', ');
conteneur.append(compteur);
}
return conteneur;
}

View File

@@ -13,21 +13,23 @@
import { import {
LIBELLES_STATUT, LIBELLES_STATUT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours, ajouterJours,
aujourdhui, aujourdhui,
bornesProjet, bornesProjet,
bornesReference, bornesReference,
cleTag,
deriveProjet, deriveProjet,
dimancheDeLaSemaine, dimancheDeLaSemaine,
ecartJours, ecartJours,
formaterDateLongue,
formulerDerive, formulerDerive,
lundiDeLaSemaine, lundiDeLaSemaine,
projetFiltre, projetFiltre,
semaineISO, semaineISO,
teinteTag,
versUTC, versUTC,
} from './model.js'; } from './model.js';
import { pastillesTags } from './tags.js';
/** /**
* Échelle unique : 6 pixels par jour, soit 42 pixels par semaine. * Échelle unique : 6 pixels par jour, soit 42 pixels par semaine.
@@ -45,16 +47,6 @@ export const PIXELS_PAR_JOUR = 6;
*/ */
const LIBELLE_MINI = { annee: 40, mois: 34, semaine: 30 }; const LIBELLE_MINI = { annee: 40, mois: 34, semaine: 30 };
const MOIS_COURTS = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.',
];
const MOIS_LONGS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
/** /**
* Calcule l'échelle : origine, largeur totale et conversions date <-> pixel. * Calcule l'échelle : origine, largeur totale et conversions date <-> pixel.
* Toute la géométrie de la frise passe par cet objet. * Toute la géométrie de la frise passe par cet objet.
@@ -272,11 +264,6 @@ function moisSuivant(annee, mois) {
return mois === 12 ? { annee: annee + 1, mois: 1 } : { annee, mois: mois + 1 }; return mois === 12 ? { annee: annee + 1, mois: 1 } : { annee, mois: mois + 1 };
} }
export function formaterDateLongue(date) {
const [a, m, j] = date.split('-');
return `${j} ${MOIS_COURTS[Number(m) - 1]} ${a}`;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Colonne des libellés // Colonne des libellés
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -392,66 +379,6 @@ function libelleProjet(projet, tagsActifs = []) {
return ligne; return ligne;
} }
/**
* Pastilles de tags d'un projet, dans sa ligne de libellé.
*
* Chaque pastille est un bouton : cliquer un tag l'ajoute au filtre, le
* recliquer l'en retire. C'est le geste le plus court pour passer d'un projet
* repéré à « montre-moi tous ceux qui lui ressemblent », sans remonter à la
* barre de filtres.
*
* Au-delà de `PASTILLES_VISIBLES`, les tags se résument à un compteur. La
* colonne est étroite et disputée : montrer quatre pastilles y revenait à les
* rogner toutes jusqu'à l'initiale, ce qui coûtait la place d'un tag sans en
* livrer le nom. Les tags masqués restent lisibles dans l'infobulle, et tous
* sont cliquables depuis la barre de filtres.
*/
const PASTILLES_VISIBLES = 2;
function pastillesTags(tags, tagsActifs) {
const conteneur = document.createElement('span');
conteneur.className = 'tags';
// Deux projets peuvent écrire le même tag différemment (« Client », « client ») :
// c'est la clé, pas la graphie, qui décide s'il est actif.
const clesActives = new Set(tagsActifs.map(cleTag));
// Un tag présent dans le filtre courant passe devant : c'est celui qui
// explique pourquoi le projet est à l'écran, il ne doit pas tomber dans le
// compteur.
const ordonnes = [...tags].sort(
(a, b) => Number(clesActives.has(cleTag(b))) - Number(clesActives.has(cleTag(a)))
);
const montres = ordonnes.slice(0, PASTILLES_VISIBLES);
const restants = ordonnes.slice(PASTILLES_VISIBLES);
for (const tag of montres) {
const pastille = document.createElement('button');
pastille.type = 'button';
pastille.className = 'tag';
pastille.style.setProperty('--teinte-tag', String(teinteTag(tag)));
pastille.textContent = tag;
pastille.dataset.action = 'tag';
pastille.dataset.tag = tag;
const actif = clesActives.has(cleTag(tag));
pastille.setAttribute('aria-pressed', String(actif));
pastille.title = actif ? `Retirer « ${tag} » du filtre` : `Filtrer sur « ${tag} »`;
conteneur.append(pastille);
}
if (restants.length) {
const compteur = document.createElement('span');
compteur.className = 'tag--reste';
compteur.textContent = `+${restants.length}`;
compteur.title = restants.join(', ');
conteneur.append(compteur);
}
return conteneur;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Couloirs // Couloirs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -21,11 +21,15 @@ import {
ecartJours, ecartJours,
fabriquerId, fabriquerId,
figerReference, figerReference,
formaterDateLongue,
formaterMois,
formaterPeriode,
formulerDerive, formulerDerive,
libererReference, libererReference,
lundiDeLaSemaine, lundiDeLaSemaine,
modifierPhase, modifierPhase,
normaliserTags, normaliserTags,
phasesParMois,
planningVide, planningVide,
pourEcriture, pourEcriture,
projetFiltre, projetFiltre,
@@ -141,6 +145,23 @@ describe('dates', () => {
} }
delete process.env.TZ; delete process.env.TZ;
}); });
test('formaterDateLongue et formaterMois rendent les dates lisibles', () => {
assert.equal(formaterDateLongue('2026-09-12'), '12 sept. 2026');
assert.equal(formaterMois('2026-09'), 'septembre 2026');
assert.equal(formaterMois('2027-01'), 'janvier 2027');
});
test('formaterPeriode ne nomme le mois que lorsque la phase en déborde', () => {
const p = (start, end, milestone = false) => ({ start, end, milestone });
assert.equal(formaterPeriode(p('2026-09-12', '2026-09-12', true)), '12');
assert.equal(formaterPeriode(p('2026-09-01', '2026-09-30')), '1 → 30');
assert.equal(formaterPeriode(p('2026-09-15', '2026-11-04')), '15 → 4 nov.');
// Franchir l'année ajoute le millésime, sans quoi « 15 → 4 janv. » se
// lirait comme un retour en arrière de huit mois.
assert.equal(formaterPeriode(p('2026-09-15', '2027-01-04')), '15 → 4 janv. 2027');
});
}); });
// --- identifiants ----------------------------------------------------------- // --- identifiants -----------------------------------------------------------
@@ -413,6 +434,108 @@ describe('bornes', () => {
}); });
}); });
// --- regroupement par mois --------------------------------------------------
describe('phasesParMois', () => {
const planning = {
version: VERSION_FORMAT,
projects: [
projet({
id: 'site-web',
name: 'Site web',
tags: ['client'],
phases: [
phase({ id: 'cadrage', name: 'Cadrage', start: '2026-09-01', end: '2026-09-20' }),
phase({
id: 'livraison',
name: 'Livraison',
start: '2026-11-12',
end: '2026-11-12',
milestone: true,
}),
],
}),
projet({
id: 'portail',
name: 'Portail',
tags: ['interne'],
phases: [
// Court sur trois mois : ne doit apparaître qu'en septembre.
phase({ id: 'redaction', name: 'Rédaction', start: '2026-09-15', end: '2026-11-04' }),
],
}),
],
};
const cles = (groupes) => groupes.map((groupe) => groupe.mois);
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);
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.
assert.deepEqual(noms(groupes[1]), ['Livraison']);
});
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);
});
test('trie les mois du plus ancien au plus récent, à cheval sur les années', () => {
const surDeuxAns = {
projects: [
projet({
phases: [
phase({ id: 'b', start: '2027-01-05', end: '2027-01-10' }),
phase({ id: 'a', start: '2026-12-05', end: '2026-12-10' }),
],
}),
],
};
assert.deepEqual(cles(phasesParMois(surDeuxAns)), ['2026-12', '2027-01']);
});
test('trie un mois par date, puis par projet, puis par nom de phase', () => {
const memeJour = {
projects: [
projet({
id: 'zeta',
name: 'Zeta',
phases: [phase({ id: 'x', name: 'Atelier', start: '2026-09-10', end: '2026-09-11' })],
}),
projet({
id: 'alpha',
name: 'Alpha',
phases: [
phase({ id: 'y', name: 'Bilan', start: '2026-09-10', end: '2026-09-11' }),
phase({ id: 'z', name: 'Amorçage', start: '2026-09-02', end: '2026-09-03' }),
],
}),
],
};
assert.deepEqual(noms(phasesParMois(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']);
const masque = {
projects: planning.projects.map((p) =>
p.id === 'portail' ? { ...p, hidden: true } : p
),
};
assert.deepEqual(noms(phasesParMois(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']), []);
});
});
// --- référence et dérive ---------------------------------------------------- // --- référence et dérive ----------------------------------------------------
describe('référence et dérive', () => { describe('référence et dérive', () => {