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

@@ -11,7 +11,8 @@ import { creerPanneau } from './detail.js';
import { creerPanneauProjet } from './projet.js';
import { creerDialogueConfirmation, creerMenu } from './menu.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 {
COULEURS,
@@ -23,6 +24,7 @@ import {
creerPhase,
creerProjet,
figerReference,
formaterDateLongue,
libererReference,
normaliserTags,
planningVide,
@@ -45,6 +47,8 @@ const refs = {
entete: $('entete'),
couloirs: $('couloirs'),
frise: $('frise'),
vueMois: $('vue-mois'),
onglets: $('onglets'),
etat: $('etat-sauvegarde'),
messageVide: $('message-vide'),
messageFiltre: $('message-filtre'),
@@ -60,6 +64,27 @@ let planning = planningVide();
let echelle = null;
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,
* 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
// message, la frise vide laisserait croire que les projets ont disparu.
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();
peindreOnglets();
if (!aDesProjets) {
echelle = null;
return;
}
if (vue === 'mois') {
rendreMois(refs.vueMois, planning, { tagsActifs });
return;
}
elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, selection, tagsActifs });
@@ -309,6 +345,49 @@ function effacerFiltres() {
refs.effacerFiltres.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
// ---------------------------------------------------------------------------
@@ -783,6 +862,12 @@ async function supprimerLeProjet(projet) {
* que celle qui sert ensuite à les modifier.
*/
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
// regardant que les projets « client », c'en est presque toujours un — et
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
@@ -795,7 +880,13 @@ function nouveauProjet() {
// 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);
$('premier-projet').addEventListener('click', nouveauProjet);