diff --git a/data/exemple.json b/data/exemple.json index f373294..727b114 100644 --- a/data/exemple.json +++ b/data/exemple.json @@ -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": [ diff --git a/docs/modele-donnees.md b/docs/modele-donnees.md index 14a096f..e21908a 100644 --- a/docs/modele-donnees.md +++ b/docs/modele-donnees.md @@ -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. diff --git a/js/model.js b/js/model.js index 54f0b6a..e93206b 100644 --- a/js/model.js +++ b/js/model.js @@ -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} »` : `n°${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: [], diff --git a/tests/model.test.js b/tests/model.test.js index 08ec322..99b9efd 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -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', () => {