Dessiner l'horizon sur la frise, l'éditer et le glisser
La barre d'un projet envisagé est pâle et hachurée, aux bords fondus. Elle se glisse et se redimensionne comme une phase, mais accrochée au mois : le geste existe, il ne permet pas de poser une date qu'on n'a pas. Le panneau ajoute deux champs de mois et trois crans. Une barre « Afficher » filtre par état, disjonctive là où celle des tags est conjonctive — d'où des boutons carrés et non des pastilles. Les écartés en sont décochés au départ. Engager démarre au premier jour de l'horizon, et clore un projet demande confirmation quand il reste des phases à écraser. Au passage : releasePointerCapture peut lever quand le pointeur a disparu, ce qui sautait l'application du geste. Le nettoyage en est maintenant isolé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
321
css/style.css
321
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;
|
||||
@@ -731,10 +840,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;
|
||||
@@ -858,6 +974,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
|
||||
@@ -1011,6 +1188,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
|
||||
@@ -1791,6 +2068,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;
|
||||
|
||||
70
index.html
70
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>
|
||||
|
||||
@@ -215,11 +236,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 +303,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
|
||||
|
||||
228
js/app.js
228
js/app.js
@@ -17,14 +17,19 @@ import { rendreMois, revelerMoisCourant } from './mois.js';
|
||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||
import {
|
||||
COULEURS,
|
||||
ETATS,
|
||||
LIBELLES_ETAT,
|
||||
STATUTS,
|
||||
ajouterJours,
|
||||
ajouterPhase,
|
||||
aujourdhui,
|
||||
bornesPlanning,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
ecarterProjet,
|
||||
etatProjet,
|
||||
figerReference,
|
||||
formaterDateLongue,
|
||||
libererReference,
|
||||
@@ -32,11 +37,16 @@ import {
|
||||
normaliserTags,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
premierJourDuMois,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
reanimerProjet,
|
||||
remplacerPhase,
|
||||
remplacerProjet,
|
||||
rouvrirProjet,
|
||||
supprimerPhase,
|
||||
supprimerProjet,
|
||||
terminerProjet,
|
||||
teinteTag,
|
||||
tousLesTags,
|
||||
validerPlanning,
|
||||
@@ -58,6 +68,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 +125,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é.
|
||||
*
|
||||
@@ -197,6 +221,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 +241,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 +276,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 +290,7 @@ function dessiner() {
|
||||
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
|
||||
|
||||
peindreFiltres();
|
||||
peindreEtats();
|
||||
peindreOnglets();
|
||||
|
||||
if (!aDesProjets) {
|
||||
@@ -265,12 +299,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 +320,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 +414,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 +857,22 @@ 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 })));
|
||||
},
|
||||
surClicProjet(idProjet) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (projet) ouvrirProjet(projet);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -854,6 +982,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 +1007,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 +1065,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 +1113,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 +1174,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 +1229,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 +1256,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 });
|
||||
@@ -1146,7 +1356,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 +1522,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);
|
||||
|
||||
151
js/drag.js
151
js/drag.js
@@ -14,8 +14,13 @@
|
||||
|
||||
import {
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
ecartMois,
|
||||
lundiDeLaSemaine,
|
||||
moisDe,
|
||||
premierJourDuMois,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
@@ -40,19 +45,45 @@ 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"]');
|
||||
if (!cible) return;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
|
||||
// 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 +120,21 @@ 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 === '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 +153,46 @@ 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 === '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 +200,8 @@ export function installerGlisser(conteneur, rappels) {
|
||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||
*/
|
||||
function appliquerGeste(geste, decalage, echelle) {
|
||||
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
||||
|
||||
const { phaseInitiale: phase, mode } = geste;
|
||||
|
||||
if (mode === 'deplacer') {
|
||||
@@ -164,6 +229,37 @@ function appliquerGeste(geste, decalage, echelle) {
|
||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}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 +269,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
|
||||
|
||||
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() {
|
||||
|
||||
116
js/timeline.js
116
js/timeline.js
@@ -12,20 +12,26 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
LIBELLES_ETAT,
|
||||
LIBELLES_STATUT,
|
||||
MOIS_COURTS,
|
||||
MOIS_LONGS,
|
||||
ajouterJours,
|
||||
aujourdhui,
|
||||
bornesHorizon,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
deriveProjet,
|
||||
dimancheDeLaSemaine,
|
||||
ecartJours,
|
||||
etatProjet,
|
||||
formaterDateLongue,
|
||||
formaterHorizon,
|
||||
formulerDerive,
|
||||
lundiDeLaSemaine,
|
||||
projetADater,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
semaineISO,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
@@ -85,19 +91,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`;
|
||||
@@ -303,6 +318,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 +327,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 +354,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 +367,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 +431,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 +460,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);
|
||||
@@ -554,6 +607,53 @@ function construireBarreCumulative(projet, echelle) {
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 barre;
|
||||
}
|
||||
|
||||
/** Barre fantôme du projet, reflétant l'étendue de référence. */
|
||||
function construireFantome(projet, echelle) {
|
||||
const reference = bornesReference(projet);
|
||||
|
||||
Reference in New Issue
Block a user