Tags sur les projets, en version 3 du format

Un projet porte désormais une liste d'étiquettes libres, destinées à filtrer la
frise. Il n'y a pas de référentiel à tenir : les tags disponibles sont ceux que
portent les projets.

normaliserTags() nettoie la liste à l'enregistrement — espaces réduits, vides
écartés, doublons fusionnés à la casse près, tri alphabétique pour qu'une
ressaisie dans un autre ordre ne produise aucun diff. Les accents, eux, comptent :
« éditeur » et « editeur » restent deux tags distincts, contrairement aux
identifiants qui doivent tenir dans une URL.

Un tag trop long est tronqué plutôt que refusé : sa longueur est cosmétique, et
bloquer le chargement d'un planning entier pour un libellé bavard serait
disproportionné. La validation ne vérifie donc que le type — un tag mal typé
serait un tag qu'on croit poser et qui ne filtre rien.

teinteTag() dérive une teinte HSL du nom par hachage : la couleur d'un tag n'est
ni stockée ni saisie, et reste la même partout où il apparaît.

Le format passe en version 3. Un fichier en version 2 se charge sans rien
demander, ses projets recevant une liste vide, et il est réécrit en version 3 à
la première sauvegarde. Le bump empêche une version antérieure de l'outil
d'effacer silencieusement les tags en réécrivant le fichier.

Dix tests couvrent la normalisation, la comparaison, la teinte et le filtre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 05:51:50 +02:00
parent 222831234a
commit 3be367488c
4 changed files with 233 additions and 6 deletions

View File

@@ -1,10 +1,11 @@
{
"version": 2,
"version": 3,
"projects": [
{
"id": "site-web",
"name": "Refonte du site web",
"color": "#3b82f6",
"tags": ["client", "web"],
"collapsed": false,
"hidden": false,
"baselineDate": "2026-06-12",
@@ -65,6 +66,7 @@
"id": "migration-infra",
"name": "Migration infrastructure",
"color": "#10b981",
"tags": ["infra", "interne"],
"collapsed": false,
"hidden": false,
"phases": [
@@ -119,6 +121,7 @@
"id": "formation-equipe",
"name": "Formation de l'équipe",
"color": "#f59e0b",
"tags": ["interne", "RH"],
"collapsed": true,
"hidden": false,
"phases": [

View File

@@ -9,12 +9,13 @@ dans un diff git et éditable à la main.
```json
{
"version": 2,
"version": 3,
"projects": [
{
"id": "site-web",
"name": "Refonte du site web",
"color": "#3b82f6",
"tags": ["client", "web"],
"collapsed": false,
"hidden": false,
"baselineDate": "2026-06-12",
@@ -48,9 +49,12 @@ dans un diff git et éditable à la main.
| Champ | Type | Description |
|---|---|---|
| `version` | entier | Version du format. Vaut `2`. Sert à détecter un fichier trop ancien au chargement. |
| `version` | entier | Version du format. Vaut `3`. Sert à détecter un fichier trop ancien au chargement. |
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
Un fichier en version `2` — celle d'avant les tags — se charge sans rien demander : ses projets
reçoivent une liste de tags vide, et il est réécrit en version `3` à la première sauvegarde.
## Projet
| Champ | Type | Description |
@@ -58,6 +62,7 @@ dans un diff git et éditable à la main.
| `id` | chaîne | Identifiant parlant, dérivé du nom (`Refonte du site web``site-web`). Unique dans le fichier. |
| `name` | chaîne | Nom affiché. Non vide. |
| `color` | chaîne | Couleur du couloir, en hexadécimal `#rrggbb`. |
| `tags` | tableau | Étiquettes libres servant à filtrer la frise. Trié, sans doublon, éventuellement vide. |
| `collapsed` | booléen | `true` si le couloir est plié. |
| `hidden` | booléen | `true` si le projet est masqué de la frise. Ses données restent intactes. |
| `baselineDate` | chaîne ou absent | Date à laquelle la référence a été figée. Absent si elle ne l'a jamais été. |
@@ -91,6 +96,36 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
Un `id` ne change jamais si le nom est modifié ensuite — il identifie, il ne décrit pas.
- `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`.
- `color` est un hexadécimal `#rrggbb` valide.
- `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés —
un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche,
est normalisé sans erreur (voir ci-dessous).
## Tags
Une étiquette libre, posée sur un projet, sans liste fermée à tenir à jour : les tags disponibles
sont simplement ceux que portent les projets.
À l'enregistrement, `normaliserTags()` nettoie la liste :
- espaces de bord retirés, espaces internes réduits à un seul ;
- tags vides écartés ;
- doublons fusionnés **à la casse près**`Client` et `client` sont le même tag, et c'est la
première graphie rencontrée qui est conservée ;
- tag tronqué au-delà de **24 caractères** (`MAX_LONGUEUR_TAG`) ;
- tri alphabétique, pour que ressaisir les mêmes tags dans un autre ordre ne produise aucun diff git.
Les **accents comptent** : `éditeur` et `editeur` restent deux tags distincts. C'est la différence
avec `fabriquerId()`, qui les retire parce qu'un identifiant doit tenir dans une URL — un tag, lui,
n'est jamais qu'affiché.
La **couleur** d'un tag n'est pas stockée : elle se déduit du nom par un hachage
(`teinteTag()` → une teinte HSL entre 0 et 359). Elle est donc stable d'une session à l'autre et
identique partout où le tag apparaît, sans rien avoir à gérer. Deux tags peuvent tomber sur des
teintes voisines : la couleur aide à repérer, elle ne porte pas d'information à elle seule — le nom
est toujours écrit à côté.
Le **filtre**, lui, n'est pas dans le fichier : c'est un état de vue, au même titre que la fenêtre
temporelle. Voir [decisions.md](decisions.md), section 16.
Un fichier invalide n'est jamais réparé en silence : le chargement échoue avec un message qui pointe
le projet et la phase fautifs, pour que le fichier puisse être corrigé à la main.

View File

@@ -12,7 +12,7 @@
* La date de fin est *incluse* : du 01/08 au 01/08 dure un jour.
*/
export const VERSION_FORMAT = 2;
export const VERSION_FORMAT = 3;
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
@@ -151,6 +151,84 @@ export function fabriquerId(nom, dejaPris = []) {
return `${base}-${compteur}`;
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
/**
* Longueur maximale d'un tag, au-delà de laquelle `normaliserTags` tronque.
*
* Un tag est une étiquette, pas une phrase. La limite est appliquée en
* normalisant plutôt qu'en refusant : elle est cosmétique, comme le statut d'une
* phase, et bloquer le chargement d'un planning entier pour un libellé trop
* bavard serait disproportionné.
*/
export const MAX_LONGUEUR_TAG = 24;
/**
* Clé de comparaison d'un tag : c'est elle qui décide que « Client » et
* « client » sont le même tag. Minuscules et espaces normalisés, mais accents
* conservés — « éditeur » et « editeur » restent deux tags distincts, contrairement
* aux identifiants (`fabriquerId`) qui, eux, doivent tenir dans une URL.
*/
export function cleTag(tag) {
return tag.trim().replace(/\s+/g, ' ').toLocaleLowerCase('fr');
}
/**
* Nettoie une liste de tags : espaces retirés, vides écartés, doublons
* fusionnés à la clé près, ordre alphabétique.
*
* Le tri rend le fichier stable : ressaisir les mêmes tags dans un autre ordre
* ne produit aucun diff git.
*/
export function normaliserTags(tags) {
const vus = new Map();
for (const brut of tags) {
const tag = String(brut).trim().replace(/\s+/g, ' ').slice(0, MAX_LONGUEUR_TAG).trim();
if (!tag) continue;
const cle = cleTag(tag);
if (!vus.has(cle)) vus.set(cle, tag);
}
return [...vus.values()].sort((a, b) => a.localeCompare(b, 'fr'));
}
/** Tous les tags employés dans le planning, dédoublonnés et triés. */
export function tousLesTags(planning) {
return normaliserTags(planning.projects.flatMap((projet) => projet.tags));
}
/**
* Vrai si le projet passe le filtre, c'est-à-dire s'il porte *tous* les tags
* sélectionnés. Un filtre vide laisse tout passer.
*
* La conjonction plutôt que la disjonction : cocher un tag de plus resserre
* toujours la sélection, ce qui rend le filtre prévisible — on part du tout et
* on élague, sans jamais voir la frise se repeupler en cochant.
*/
export function projetFiltre(projet, tagsActifs) {
if (!tagsActifs.length) return true;
const cles = new Set(projet.tags.map(cleTag));
return tagsActifs.every((tag) => cles.has(cleTag(tag)));
}
/**
* Teinte HSL attribuée à un tag, entre 0 et 359.
*
* Dérivée du nom par un hachage, donc stable d'une session à l'autre et
* identique partout où le tag apparaît, sans rien avoir à stocker. Deux tags
* peuvent tomber sur des teintes voisines : la couleur aide à repérer, elle ne
* porte pas d'information à elle seule — le nom est toujours écrit à côté.
*/
export function teinteTag(tag) {
const cle = cleTag(tag);
let hachage = 0;
for (let i = 0; i < cle.length; i += 1) {
hachage = (hachage * 31 + cle.charCodeAt(i)) % 360;
}
return hachage;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
@@ -214,6 +292,7 @@ export function validerPlanning(donnees) {
id: projet.id,
name: projet.name.trim(),
color: MOTIF_COULEUR.test(projet.color || '') ? projet.color : COULEURS[0],
tags: validerTags(projet.tags, repere),
collapsed: Boolean(projet.collapsed),
hidden: Boolean(projet.hidden),
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
@@ -224,6 +303,24 @@ export function validerPlanning(donnees) {
return { version: VERSION_FORMAT, projects: projets };
}
/**
* Les tags sont facultatifs — un fichier en version 2 n'en a aucun — mais s'ils
* sont là, ils doivent être des chaînes : un tag avalé en silence parce qu'il
* était mal typé serait un tag qu'on croit poser et qui ne filtre rien.
*/
function validerTags(tags, repere) {
if (tags === undefined) return [];
if (!Array.isArray(tags)) {
throw new ErreurValidation(`Projet ${repere} : « tags » doit être un tableau.`);
}
for (const tag of tags) {
if (typeof tag !== 'string') {
throw new ErreurValidation(`Projet ${repere} : un tag n'est pas une chaîne.`);
}
}
return normaliserTags(tags);
}
function validerPhase(phase, rang, repereProjet, idsPhases) {
const repere = phase && phase.name ? `« ${phase.name} »` : `${rang + 1}`;
const ou = `Projet ${repereProjet}, phase ${repere}`;
@@ -406,12 +503,13 @@ export function formulerDerive(jours) {
// Modifications
// ---------------------------------------------------------------------------
export function creerProjet(planning, nom) {
export function creerProjet(planning, nom, tags = []) {
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
return {
id,
name: nom.trim(),
color: COULEURS[planning.projects.length % COULEURS.length],
tags: normaliserTags(tags),
collapsed: false,
hidden: false,
phases: [],

View File

@@ -3,11 +3,14 @@ import assert from 'node:assert/strict';
import {
ErreurValidation,
MAX_LONGUEUR_TAG,
VERSION_FORMAT,
ajouterJours,
ajouterPhase,
bornesPlanning,
bornesProjet,
bornesReference,
cleTag,
creerPhase,
creerProjet,
dateValide,
@@ -22,11 +25,15 @@ import {
libererReference,
lundiDeLaSemaine,
modifierPhase,
normaliserTags,
planningVide,
pourEcriture,
projetFiltre,
redimensionnerPhase,
remplacerPhase,
semaineISO,
teinteTag,
tousLesTags,
validerPlanning,
} from '../js/model.js';
@@ -50,6 +57,7 @@ function projet(champs = {}) {
id: 'site-web',
name: 'Site web',
color: '#3b82f6',
tags: [],
collapsed: false,
hidden: false,
phases: [],
@@ -168,7 +176,11 @@ describe('validerPlanning', () => {
projects: [projet({ phases: [phase()] })],
});
assert.equal(resultat.version, 2);
// Un fichier plus ancien est relu et remonté à la version courante : c'est
// ce qui permet à un planning écrit avant les tags de s'ouvrir sans rien
// demander, ses projets recevant simplement une liste de tags vide.
assert.equal(resultat.version, VERSION_FORMAT);
assert.deepEqual(resultat.projects[0].tags, []);
assert.equal(resultat.projects.length, 1);
assert.equal(resultat.projects[0].phases[0].id, 'cadrage');
});
@@ -283,6 +295,85 @@ describe('validerPlanning', () => {
});
});
// --- tags -------------------------------------------------------------------
describe('tags', () => {
test('normaliserTags nettoie, dédoublonne et trie', () => {
assert.deepEqual(
normaliserTags([' web ', 'client', '', ' ', 'Client', 'a\t b']),
['a b', 'client', 'web']
);
});
test('normaliserTags garde la première graphie rencontrée', () => {
assert.deepEqual(normaliserTags(['Client', 'client', 'CLIENT']), ['Client']);
});
test('normaliserTags tronque un tag trop bavard plutôt que de le refuser', () => {
const [tag] = normaliserTags(['x'.repeat(MAX_LONGUEUR_TAG + 10)]);
assert.equal(tag.length, MAX_LONGUEUR_TAG);
});
test('cleTag ignore la casse et les espaces, mais pas les accents', () => {
assert.equal(cleTag(' Sous-Traitance '), cleTag('sous-traitance'));
assert.notEqual(cleTag('éditeur'), cleTag('editeur'));
});
test('teinteTag est déterministe et reste dans la roue chromatique', () => {
assert.equal(teinteTag('client'), teinteTag('Client'));
for (const tag of ['client', 'web', 'interne', 'R&D', 'éditeur']) {
const teinte = teinteTag(tag);
assert.ok(teinte >= 0 && teinte < 360, `${tag}${teinte}`);
}
});
test('tousLesTags rassemble ceux du planning, sans doublon', () => {
const planning = {
projects: [
projet({ id: 'a', tags: ['web', 'client'] }),
projet({ id: 'b', tags: ['Client', 'interne'] }),
projet({ id: 'c' }),
],
};
assert.deepEqual(tousLesTags(planning), ['client', 'interne', 'web']);
});
test('projetFiltre exige tous les tags cochés, pas seulement un', () => {
const p = projet({ tags: ['client', 'urgent', 'web'] });
assert.equal(projetFiltre(p, []), true);
assert.equal(projetFiltre(p, ['client']), true);
assert.equal(projetFiltre(p, ['client', 'urgent']), true);
assert.equal(projetFiltre(p, ['client', 'interne']), false);
assert.equal(projetFiltre(projet({ tags: [] }), ['client']), false);
});
test('projetFiltre compare à la casse près', () => {
assert.equal(projetFiltre(projet({ tags: ['Client'] }), ['client']), true);
});
test('validerPlanning normalise les tags et rejette ceux qui ne sont pas des chaînes', () => {
const resultat = validerPlanning({
projects: [projet({ tags: ['web ', 'Client', 'client'] })],
});
assert.deepEqual(resultat.projects[0].tags, ['Client', 'web']);
assert.throws(
() => validerPlanning({ projects: [projet({ tags: 'client' })] }),
(err) => err instanceof ErreurValidation && /tags/.test(err.message)
);
assert.throws(
() => validerPlanning({ projects: [projet({ tags: [42] })] }),
(err) => err instanceof ErreurValidation && /Site web/.test(err.message)
);
});
test('creerProjet accepte des tags et les normalise', () => {
const p = creerProjet(planningVide(), 'Site web', [' web', 'Client', 'client']);
assert.deepEqual(p.tags, ['Client', 'web']);
});
});
// --- bornes -----------------------------------------------------------------
describe('bornes', () => {