diff --git a/composables/useFicheAdapter.ts b/composables/useFicheAdapter.ts index 3b3caa9..eae841d 100644 --- a/composables/useFicheAdapter.ts +++ b/composables/useFicheAdapter.ts @@ -1,12 +1,18 @@ /** - * useFicheAdapter — B3-M2 + * useFicheAdapter — B3-M2, étendu en B11-M2 * Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par - * FicheMiniCard / FichesPanel. Aucune mutation des types sources. + * FicheMiniCard / VueFiches. Aucune mutation des types sources. * * Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas * encore) : première phrase du premier champ non vide parmi une liste de * candidats ordonnée par type, tronquée proprement à ~110 caractères sur un * mot (jamais au milieu). + * + * B11 : chaque fiche porte ses `etiquettes` par dimension (clé = paramètre + * d'URL de la dimension : `fonctions`, `echelle`, `territoire` pour l'Entraide ; + * `tags`, `famille`, `pays` pour Réseaux AEP). C'est ce que filtre, trie et + * regroupe `utils/vueFiches.ts` ; `chips` et `echelle` restent l'affichage + * de la carte-fiche, inchangé. */ import type { Org } from '~/types/org' import type { StructureV2 } from '~/types/structure-v2' @@ -15,12 +21,25 @@ export interface MiniFiche { id: string | number nom: string resume: string + /** Étiquettes affichées sur la carte-fiche (3 au plus). */ chips: string[] + /** Dimension d'où viennent `chips` : les rend cliquables dans la vue fiches. */ + chipsDimension?: string echelle?: string geoloc: boolean kind: 'org' | 'structure' + /** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */ + etiquettes?: Record + coords?: [number, number] + /** Date ISO si la source en a une (tri « récent »). */ + date?: string + ville?: string + /** Texte cherchable en plus du nom, de la ville et du résumé. */ + texte?: string } +export const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] + const RESUME_MAX = 110 /** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */ @@ -53,32 +72,68 @@ function orgFonctions(org: Org): string[] { .split(',') .map((f) => f.trim()) .filter(Boolean) - .slice(0, 3) } -export function useFicheAdapter() { - function toMini(source: Org | StructureV2): MiniFiche { - if (isStructure(source)) { - return { - id: source.id, - nom: source.nom, - resume: buildResume([source.description_courte, source.description_longue]), - chips: (source.hashtags ?? []).slice(0, 3), - geoloc: source.latitude != null && source.longitude != null, - kind: 'structure', - } - } +function coordsDe(lat?: number | null, lon?: number | null): [number, number] | undefined { + return lat != null && lon != null ? [lat, lon] : undefined +} +/** Hashtag sans son « # » : c'est la forme de l'URL (`tags=reemploi-structurel`). */ +export function sansDiese(tag: string): string { + return tag.replace(/^#/, '') +} + +/** Familles d'une structure : principale, secondaires, et 6 pour le badge recherche politique. */ +function famillesDe(s: StructureV2): string[] { + const ids = [s.famille_principale, ...(s.familles_secondaires ?? [])] + if (s.badges?.f6_recherche_politique) ids.push(6 as any) + return [...new Set(ids.map(String))] +} + +export function toMini(source: Org | StructureV2): MiniFiche { + if (isStructure(source)) { + const hashtags = source.hashtags ?? [] return { - id: source.Id, + id: source.id, nom: source.nom, - resume: buildResume([source.description_enrichie, source.description]), - chips: orgFonctions(source), - echelle: source.echelle, + resume: buildResume([source.description_courte, source.description_longue]), + chips: hashtags.slice(0, 3), + chipsDimension: 'tags', geoloc: source.latitude != null && source.longitude != null, - kind: 'org', + kind: 'structure', + etiquettes: { + tags: hashtags.map(sansDiese), + famille: famillesDe(source), + pays: source.pays ? [source.pays] : [], + }, + coords: coordsDe(source.latitude, source.longitude), + ville: source.ville, + // Portée de la recherche d'avant B11 sur /agences : description courte et hashtags. + texte: [source.description_courte, ...hashtags].filter(Boolean).join(' '), } } + const fonctions = orgFonctions(source) + return { + id: source.Id, + nom: source.nom, + resume: buildResume([source.description_enrichie, source.description]), + chips: fonctions.slice(0, 3), + chipsDimension: 'fonctions', + echelle: source.echelle, + geoloc: source.latitude != null && source.longitude != null, + kind: 'org', + etiquettes: { + fonctions, + echelle: source.echelle ? [source.echelle] : [], + territoire: [source.territoire && DOM_TOM.includes(source.territoire) ? source.territoire : 'Métropole'], + }, + coords: coordsDe(source.latitude, source.longitude), + date: source.submitted_at, + ville: source.localisation_ville, + } +} + +export function useFicheAdapter() { return { toMini } } diff --git a/composables/useVueFiches.ts b/composables/useVueFiches.ts new file mode 100644 index 0000000..33bb17d --- /dev/null +++ b/composables/useVueFiches.ts @@ -0,0 +1,165 @@ +/** + * useVueFiches — B11-M1. L'état de la vue fiches, branché sur l'URL. + * + * L'URL est la source de vérité (SPEC-vue-fiches §5) : l'état se LIT dans + * `route.query` (surveillée : un lien interne vers `?vue=fiches` depuis la même + * page change l'écran) et s'ÉCRIT par `router.replace` (pas d'entrée + * d'historique par clic de filtre). Seule la recherche a un tampon local, pour + * ne pas perdre de lettres pendant la frappe : elle part dans l'URL 250 ms + * après la dernière touche. + * + * La page crée l'état (elle en a besoin pour sa carte) et le passe tel quel à + * ``. Logique pure et tests : `utils/vueFiches.ts`, + * `scripts/test-vue-fiches.mjs`. + */ +import type { MaybeRefOrGetter } from 'vue' +import type { MiniFiche } from '~/composables/useFicheAdapter' +import { + lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes, + triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs, + type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches, +} from '~/utils/vueFiches' + +/** Largeur d'écran ; null avant le montage (SSR) : la CSS tranche alors seule. */ +export type Largeur = 'telephone' | 'tablette' | 'ordinateur' | null + +const CLE_REPLI = 'aep_vue_fiches_liste_repliee' + +export function useVueFiches( + fiches: MaybeRefOrGetter, + config: MaybeRefOrGetter, +) { + const route = useRoute() + const router = useRouter() + const cfg = computed(() => toValue(config)) + + // ── Lecture ── + const brut = computed(() => lireQuery(route.query, cfg.value)) + + const q = ref(brut.value.q) + let qEcrit = q.value + let minuteurQ: ReturnType | null = null + watch(() => brut.value.q, (v) => { + if (v !== qEcrit) { q.value = v; qEcrit = v } + }) + + const etat = computed(() => ({ + ...brut.value, + q: q.value, + filtres: filtresValides(brut.value.filtres, cfg.value), + })) + + // ── Écriture ── + function ecrire(partiel: Partial) { + const nouvel = { ...etat.value, ...partiel } + qEcrit = nouvel.q + if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null } + router.replace({ query: ecrireQuery(nouvel, cfg.value, route.query) }) + } + + function setQ(v: string) { + q.value = v + if (minuteurQ) clearTimeout(minuteurQ) + minuteurQ = setTimeout(() => { minuteurQ = null; ecrire({ q: v }) }, 250) + } + + function avecFiltre(dim: string, valeurs: string[]) { + return { ...etat.value.filtres, [dim]: valeurs } + } + function toggle(dim: string, valeur: string) { + const vals = etat.value.filtres[dim] ?? [] + ecrire({ filtres: avecFiltre(dim, vals.includes(valeur) ? vals.filter((v) => v !== valeur) : [...vals, valeur]) }) + } + function ajouterFiltre(dim: string, valeur: string) { + const vals = etat.value.filtres[dim] ?? [] + if (!vals.includes(valeur)) ecrire({ filtres: avecFiltre(dim, [...vals, valeur]) }) + } + function effacerTout() { + q.value = '' + const vides: Record = {} + for (const d of cfg.value.dimensions) vides[d.cle] = [] + ecrire({ q: '', filtres: vides, sansAdresse: false }) + } + const setTri = (tri: TriFiches) => ecrire({ tri }) + const setGroupe = (groupe: string | null) => ecrire({ groupe }) + const setSousVue = (sousVue: string) => ecrire({ sousVue }) + const setSansAdresse = (sansAdresse: boolean) => ecrire({ sansAdresse }) + + // ── Largeur et état affiché ── + const largeur = ref(null) + const listeRepliee = ref(false) + + onMounted(() => { + const mq768 = window.matchMedia('(min-width: 768px)') + const mq1024 = window.matchMedia('(min-width: 1024px)') + const maj = () => { + largeur.value = mq1024.matches ? 'ordinateur' : mq768.matches ? 'tablette' : 'telephone' + } + maj() + mq768.addEventListener('change', maj) + mq1024.addEventListener('change', maj) + onBeforeUnmount(() => { + mq768.removeEventListener('change', maj) + mq1024.removeEventListener('change', maj) + }) + try { listeRepliee.value = sessionStorage.getItem(CLE_REPLI) === '1' } catch {} + }) + + /** + * État rendu. `auto` avant le montage quand l'URL ne tranche pas (ou dit + * `mixte`) : la CSS rend Mixte à partir de 1024 px et Fiches en dessous, sans + * saut à l'hydratation. Mixte n'existe pas sous 1024 px : il se lit Fiches. + */ + const vueAffichee = computed(() => { + const etats = cfg.value.etats + const v = etat.value.vue + if (largeur.value === null) return v && v !== 'mixte' ? v : 'auto' + const ordinateur = largeur.value === 'ordinateur' + if (v === 'mixte' || v === null) { + if (!ordinateur || !etats.includes('mixte')) return 'fiches' + if (v === null && listeRepliee.value) return 'carte' + return 'mixte' + } + return v + }) + + function setVue(v: EtatVue) { + if (largeur.value === 'ordinateur' && (v === 'mixte' || v === 'carte')) { + listeRepliee.value = v === 'carte' + try { sessionStorage.setItem(CLE_REPLI, listeRepliee.value ? '1' : '0') } catch {} + } + ecrire({ vue: v }) + } + + // ── Résultat ── + const sansAdresseIgnore = computed(() => ({ ...etat.value, sansAdresse: false })) + const filtrees = computed(() => filtrerFiches(toValue(fiches), etat.value, cfg.value)) + const tri = computed(() => triEffectif(etat.value, cfg.value)) + const resultat = computed(() => trierFiches(filtrees.value, tri.value, etat.value)) + const groupeDef = computed(() => cfg.value.groupes.find((g) => g.cle === etat.value.groupe) ?? null) + const groupes = computed(() => regrouper(resultat.value, groupeDef.value, cfg.value)) + const ordre = computed(() => ordreFiches(groupes.value)) + const compteurs = computed(() => compterEtiquettes(toValue(fiches), etat.value, cfg.value)) + const idsResultat = computed(() => new Set(resultat.value.map((f) => String(f.id)))) + const nbSansAdresse = computed(() => + filtrerFiches(toValue(fiches), sansAdresseIgnore.value, cfg.value).filter((f) => !f.geoloc).length, + ) + const total = computed(() => toValue(fiches).length) + const nbActifs = computed(() => nbFiltresActifs(etat.value)) + + function voirSansAdresse() { + ecrire({ sansAdresse: true, vue: 'fiches' }) + } + + /** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */ + const survolId = ref(null) + + return reactive({ + cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs, + idsResultat, nbSansAdresse, total, nbActifs, survolId, + setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue, + setSansAdresse, voirSansAdresse, + }) +} + +export type VueFichesEtat = ReturnType diff --git a/scripts/test-vue-fiches.mjs b/scripts/test-vue-fiches.mjs new file mode 100644 index 0000000..d244354 --- /dev/null +++ b/scripts/test-vue-fiches.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Tests de la vue fiches (B11-M1) : logique pure de utils/vueFiches.ts et + * adaptateurs de composables/useFicheAdapter.ts, chargés tels quels via jiti + * (même patron que test-echelles-nav.mjs). + * Usage : node scripts/test-vue-fiches.mjs + */ + +import { createJiti } from 'jiti' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const jiti = createJiti(import.meta.url) +const V = await jiti.import(join(__dirname, '..', 'utils', 'vueFiches.ts')) +const A = await jiti.import(join(__dirname, '..', 'composables', 'useFicheAdapter.ts')) + +let n = 0 +let fail = 0 +function ok(cond, libelle, detail = '') { + n++ + if (!cond) fail++ + console.log(`${cond ? 'OK ' : 'FAIL'} ${libelle}${!cond && detail ? ` → ${detail}` : ''}`) +} +const trie = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.fromEntries(Object.keys(o).sort().map((k) => [k, trie(o[k])])) : o) +const eq = (a, b) => JSON.stringify(trie(a)) === JSON.stringify(trie(b)) + +// ── Données : configuration Entraide, fiches adaptées depuis de vrais Org ── +const FONCTIONS = ['Juridique', 'Technique', 'Formation', 'Santé mentale'] +const cfg = { + dimensions: [ + { cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: FONCTIONS.map((v) => ({ valeur: v, libelle: v })) }, + { cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: ['National', 'Régional', 'Local'].map((v) => ({ valeur: v, libelle: v })) }, + ], + tris: ['pertinence', 'nom', 'recent'], + groupes: [ + { cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' }, + { cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' }, + { cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire' }, + ], + etats: ['carte', 'mixte', 'fiches'], + sousVues: { parametre: 'mode', defaut: 'metropole', valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }] }, +} + +const orgs = [ + { Id: 1, nom: 'CNOA', echelle: 'National', tags_fonction: 'Juridique', localisation_ville: 'Paris', latitude: 48.8, longitude: 2.3, description: "Ordre des architectes. Deuxième phrase." }, + { Id: 2, nom: 'Collectif Fil', echelle: 'Régional', tags_fonction: 'Formation,Juridique', localisation_ville: 'Nantes', latitude: 47.2, longitude: -1.5, description: 'Recherche-action en architecture.' }, + { Id: 3, nom: 'Écoute Archi', echelle: 'Local', tags_fonction: 'Santé mentale', localisation_ville: 'Fort-de-France', territoire: 'Martinique', latitude: null, longitude: null, description: 'Soutien psychologique.' }, + { Id: 4, nom: 'Bureau Technique', echelle: 'Régional', tags_fonction: 'Technique', localisation_ville: 'Lyon', latitude: 45.7, longitude: 4.8, submitted_at: '2026-09-01T10:00:00Z' }, + { Id: 5, nom: 'Atelier sans tag', localisation_ville: 'Rennes', latitude: 48.1, longitude: -1.6 }, +] +const fiches = orgs.map(A.toMini) + +const etat0 = (over = {}) => ({ ...V.lireQuery({}, cfg), ...over }) +const ids = (fs) => fs.map((f) => f.id) + +// ── Adaptateurs (M2) ── +ok(eq(fiches[1].etiquettes, { fonctions: ['Formation', 'Juridique'], echelle: ['Régional'], territoire: ['Métropole'] }), 'org → etiquettes par dimension (fonctions complètes, échelle, territoire)') +ok(fiches[2].etiquettes.territoire[0] === 'Martinique' && fiches[2].geoloc === false && fiches[2].coords === undefined, 'org DOM sans coordonnées → territoire Martinique, geoloc false, coords absentes') +ok(eq(fiches[0].coords, [48.8, 2.3]) && fiches[0].resume === 'Ordre des architectes.', 'org → coords et résumé (première phrase) inchangé') +ok(eq(fiches[1].chips, ['Formation', 'Juridique']) && fiches[1].chipsDimension === 'fonctions' && fiches[1].echelle === 'Régional', 'org → chips et echelle d’affichage inchangés') +const structure = A.toMini({ + id: 'f1-rotor', nom: 'Rotor', url: '', pays: 'BE', ville: 'Bruxelles', famille_principale: 1, familles_secondaires: [3], + hashtags: ['#reemploi-structurel', '#plateforme-materiaux'], type_principal: 'Coopérative', + badges: { centre_ressources: true, mouvement_manifeste: false, contre_pouvoir_spatial: false, f6_recherche_politique: true }, + description_courte: 'Réemploi des matériaux de construction.', description_longue: '', pensees: [], sources: [], already_in_v1: false, eligible_v2: true, + latitude: 50.8, longitude: 4.3, +}) +ok(eq(structure.etiquettes, { tags: ['reemploi-structurel', 'plateforme-materiaux'], famille: ['1', '3', '6'], pays: ['BE'] }), 'structure → tags sans #, familles principale + secondaires + 6 (badge), pays') +ok(eq(structure.chips, ['#reemploi-structurel', '#plateforme-materiaux']) && structure.chipsDimension === 'tags', 'structure → chips d’affichage avec # inchangés') + +// ── URL ↔ état ── +const q1 = { vue: 'fiches', q: 'ordre', fonctions: 'Juridique,Formation', echelle: 'Régional', tri: 'nom', groupe: 'fonction', mode: 'outremer', adresse: 'sans' } +const e1 = V.lireQuery(q1, cfg) +ok(e1.vue === 'fiches' && e1.q === 'ordre' && eq(e1.filtres, { fonctions: ['Juridique', 'Formation'], echelle: ['Régional'] }) && e1.tri === 'nom' && e1.groupe === 'fonction' && e1.sousVue === 'outremer' && e1.sansAdresse, 'lecture : tous les paramètres de la SPEC §5') +ok(eq(V.ecrireQuery(e1, cfg), q1), 'aller-retour URL → état → URL identique') +const e2 = etat0({ vue: 'mixte', filtres: { fonctions: ['Technique'], echelle: [] }, groupe: 'echelle' }) +ok(eq(V.lireQuery(V.ecrireQuery(e2, cfg), cfg), e2), 'aller-retour état → URL → état identique') +ok(eq(V.ecrireQuery(etat0(), cfg), {}), 'état par défaut → URL vide (sous-vue par défaut non écrite)') +ok(eq(V.ecrireQuery(etat0({ q: 'x' }), cfg, { territoire: 'Guyane', random: '1', vue: 'carte', q: 'ancien' }), { territoire: 'Guyane', random: '1', q: 'x' }), 'paramètres non gérés (territoire, random) conservés, gérés remplacés') + +// Rétrocompatibilité et paramètres invalides +ok(V.lireQuery({ vue: 'fiches' }, cfg).vue === 'fiches', 'rétrocompat : ?vue=fiches (lien B3 diffusé) → état Fiches') +ok(V.lireQuery({ mode: 'outremer' }, cfg).sousVue === 'outremer', 'rétrocompat : ?mode=outremer → sous-vue Outre-mer') +ok(V.lireQuery({}, cfg).vue === null, 'sans vue= → null (défaut par largeur)') +const inv = V.lireQuery({ vue: 'plein-ecran', tri: 'distance', groupe: 'couleur', mode: 'lune', adresse: 'oui', zzz: '1' }, cfg) +ok(inv.vue === null && inv.tri === null && inv.groupe === null && inv.sousVue === 'metropole' && inv.sansAdresse === false, 'paramètres invalides ignorés sans erreur (vue, tri, groupe, mode, adresse)') +ok(eq(V.filtresValides({ fonctions: ['Juridique', 'Astrologie'], echelle: ['Mondial'] }, cfg), { fonctions: ['Juridique'], echelle: [] }), 'valeurs de filtre inconnues ignorées') +const cfgVide = { ...cfg, dimensions: [{ ...cfg.dimensions[0], valeurs: [] }] } +ok(eq(V.filtresValides({ fonctions: ['Astrologie'] }, cfgVide), { fonctions: ['Astrologie'] }), 'dimension pas encore chargée : valeurs gardées (lien partagé non perdu)') +ok(eq(V.lireQuery({ fonctions: ['Juridique,Formation', 'x'], q: ['a', 'b'] }, cfg).filtres.fonctions, ['Juridique', 'Formation']), 'paramètre répété : seule la première occurrence est lue') +ok(eq(V.lireQuery({ fonctions: ' Juridique ,,Juridique' }, cfg).filtres.fonctions, ['Juridique']), 'espaces, vides et doublons nettoyés') + +// ── Filtrage : OU dans une dimension, ET entre ── +const filtre = (over) => ids(V.filtrerFiches(fiches, etat0(over), cfg)) +ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: [] } }), [1, 2, 4]), 'OU dans une dimension : Juridique OU Technique') +ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: ['Régional'] } }), [2, 4]), 'ET entre dimensions : (Juridique OU Technique) ET Régional') +ok(eq(filtre({ filtres: { fonctions: [], echelle: ['National', 'Local'] } }), [1, 3]), 'OU sur la dimension secondaire') +ok(eq(filtre({ q: 'ecoute' }), [3]), 'recherche insensible aux accents et à la casse (« ecoute » → Écoute)') +ok(eq(filtre({ q: 'NANTES' }), [2]), 'recherche sur la ville') +ok(eq(filtre({ q: 'psychologique' }), [3]), 'recherche sur le résumé') +ok(eq(filtre({ sansAdresse: true }), [3]), 'adresse=sans → seulement les fiches non localisées') + +// ── Compteurs ── +const c = V.compterEtiquettes(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: ['Régional'] } }), cfg) +ok(c.fonctions.Juridique === 1 && c.fonctions.Technique === 1 && c.fonctions.Formation === 1 && c.fonctions['Santé mentale'] === 0, 'compteurs fonction : dans le résultat des AUTRES dimensions (Régional)', JSON.stringify(c.fonctions)) +ok(c.echelle.National === 1 && c.echelle.Régional === 1 && c.echelle.Local === 0, 'compteurs échelle : dans le résultat des autres dimensions (Juridique)', JSON.stringify(c.echelle)) +const cq = V.compterEtiquettes(fiches, etat0({ q: 'paris' }), cfg) +ok(cq.fonctions.Juridique === 1 && cq.fonctions.Formation === 0, 'compteurs : la recherche s’applique') +ok(V.nbFiltresActifs(etat0({ filtres: { fonctions: ['A', 'B'], echelle: ['C'] }, sansAdresse: true })) === 4, 'nombre de filtres actifs (pour « Filtrer · n »)') + +// ── Tri ── +ok(V.triEffectif(etat0(), cfg) === 'nom', 'tri par défaut sans filtre : nom') +ok(V.triEffectif(etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'pertinence', 'tri par défaut avec filtre : pertinence') +ok(V.triEffectif(etat0({ tri: 'recent', filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'recent', 'tri explicite prioritaire') +ok(eq(ids(V.trierFiches(fiches, 'nom', etat0())), [5, 4, 1, 2, 3]), 'tri nom A → Z (« Écoute » rangé à E)') +const ePert = etat0({ filtres: { fonctions: ['Juridique', 'Formation'], echelle: [] } }) +ok(eq(ids(V.trierFiches(V.filtrerFiches(fiches, ePert, cfg), 'pertinence', ePert)), [2, 1]), 'pertinence : la fiche qui coche le plus d’étiquettes d’abord') +ok(eq(ids(V.trierFiches(fiches, 'recent', etat0())), [4, 5, 3, 2, 1]), 'récent : date d’abord, puis Id décroissant') +ok(V.triEffectif(etat0(), { ...cfg, tris: ['pertinence', 'nom'] }) === 'nom', 'Réseaux AEP (sans récent) : défaut nom') + +// ── Regroupement ── +const g = V.regrouper(V.trierFiches(fiches, 'nom', etat0()), cfg.groupes[0], cfg) +ok(eq(g.map((x) => `${x.libelle}:${ids(x.fiches).join('+')}`), ['Juridique:1', 'Technique:4', 'Formation:2', 'Santé mentale:3', 'Sans fonction:5']), 'groupe fonction : première étiquette, ordre de la dimension, « sans » en dernier', JSON.stringify(g.map((x) => x.libelle))) +ok(g.reduce((s, x) => s + x.fiches.length, 0) === fiches.length, 'regroupement : chaque fiche une seule fois (compteurs justes)') +const gt = V.regrouper(fiches, cfg.groupes[2], cfg) +ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoire (dimension sans chips) : ordre A → Z') +ok(V.regrouper(fiches, null, cfg).length === 1, 'sans regroupement : un seul bloc') +ok(eq(ids(V.ordreFiches(g)), [1, 4, 2, 3, 5]), 'ordre fiche à fiche = ordre affiché des groupes (B12)') + +console.log(`\n${n - fail}/${n}`) +process.exit(fail ? 1 : 0) diff --git a/utils/vueFiches.ts b/utils/vueFiches.ts new file mode 100644 index 0000000..df12c89 --- /dev/null +++ b/utils/vueFiches.ts @@ -0,0 +1,297 @@ +/** + * vueFiches — B11-M1. Logique pure de la vue fiches (SPEC-vue-fiches §2-§5) : + * lecture et écriture de l'URL, filtrage, compteurs, tri, regroupement. + * Aucune dépendance à Vue ni au routeur : `composables/useVueFiches.ts` + * branche ces fonctions sur la route, `scripts/test-vue-fiches.mjs` les teste. + * + * Grammaire des filtres : OU dans une dimension, ET entre dimensions. + * L'URL est la source de vérité ; un paramètre inconnu ou invalide est ignoré. + */ +import type { MiniFiche } from '../composables/useFicheAdapter' + +export type EtatVue = 'carte' | 'mixte' | 'fiches' +export type TriFiches = 'pertinence' | 'nom' | 'recent' + +export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches'] +export const LIBELLES_TRI: Record = { + pertinence: 'Pertinence', + nom: 'A → Z', + recent: 'Récentes', +} + +export interface ValeurFiltre { + valeur: string + libelle: string +} + +export interface DimensionFiltre { + /** Paramètre d'URL ET clé de `MiniFiche.etiquettes`. */ + cle: string + libelle: string + valeurs: ValeurFiltre[] + variante: 'fonction' | 'echelle' | 'posture' +} + +export interface GroupeDef { + /** Valeur de `groupe=` dans l'URL. */ + cle: string + libelle: string + /** Clé de `MiniFiche.etiquettes` : la fiche va sous sa PREMIÈRE étiquette. */ + dimension: string + /** En-tête des fiches qui n'ont aucune étiquette dans cette dimension. */ + sans: string + /** Libellés et ordre des groupes, sinon ceux de la dimension de filtre, sinon A → Z. */ + valeurs?: ValeurFiltre[] +} + +export interface SousVuesCarte { + /** Paramètre d'URL : `mode` sur `/` (existant), `carte` sur `/agences`. */ + parametre: string + valeurs: ValeurFiltre[] + defaut: string +} + +export interface ConfigVueFiches { + dimensions: DimensionFiltre[] + tris: TriFiches[] + groupes: GroupeDef[] + etats: EtatVue[] + sousVues?: SousVuesCarte +} + +export interface EtatFiches { + /** null = pas de `vue=` dans l'URL : défaut par largeur (Mixte sur ordinateur, Fiches sur téléphone). */ + vue: EtatVue | null + q: string + filtres: Record + /** `adresse=sans` : seulement les fiches sans coordonnées (lien « N sans adresse »). */ + sansAdresse: boolean + /** null = défaut automatique (pertinence dès qu'un filtre est actif, sinon nom). */ + tri: TriFiches | null + /** null = aucun regroupement. */ + groupe: string | null + sousVue: string +} + +export interface GroupeRendu { + cle: string + libelle: string + fiches: MiniFiche[] +} + +type Query = Record + +function premiere(v: unknown): string { + if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '' + return typeof v === 'string' ? v : '' +} + +function liste(v: unknown): string[] { + const brut = premiere(v) + return [...new Set(brut.split(',').map((s) => s.trim()).filter(Boolean))] +} + +/** Minuscules, sans accents : « Régional » et « regional » se valent. */ +export function normaliser(s: string): string { + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() +} + +// ── URL ↔ état ───────────────────────────────────────────────────────────── + +export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches { + const vueBrute = premiere(query.vue) as EtatVue + const triBrut = premiere(query.tri) as TriFiches + const groupeBrut = premiere(query.groupe) + const sv = cfg.sousVues + const svBrute = sv ? premiere(query[sv.parametre]) : '' + + const filtres: Record = {} + for (const d of cfg.dimensions) filtres[d.cle] = liste(query[d.cle]) + + return { + vue: cfg.etats.includes(vueBrute) ? vueBrute : null, + q: premiere(query.q), + filtres, + sansAdresse: premiere(query.adresse) === 'sans', + tri: cfg.tris.includes(triBrut) ? triBrut : null, + groupe: cfg.groupes.some((g) => g.cle === groupeBrut) ? groupeBrut : null, + sousVue: sv && sv.valeurs.some((v) => v.valeur === svBrute) ? svBrute : (sv?.defaut ?? ''), + } +} + +/** + * Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas + * (`territoire`, `random`, `fiche` de B12…) sont recopiés depuis `base`. + */ +export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record { + const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)]) + if (cfg.sousVues) geres.add(cfg.sousVues.parametre) + + const out: Record = {} + for (const [k, v] of Object.entries(base)) { + if (!geres.has(k) && typeof premiere(v) === 'string' && premiere(v) !== '') out[k] = premiere(v) + } + if (etat.vue) out.vue = etat.vue + if (etat.q.trim()) out.q = etat.q + for (const d of cfg.dimensions) { + const vals = etat.filtres[d.cle] ?? [] + if (vals.length) out[d.cle] = vals.join(',') + } + if (etat.sansAdresse) out.adresse = 'sans' + if (etat.tri) out.tri = etat.tri + if (etat.groupe) out.groupe = etat.groupe + if (cfg.sousVues && etat.sousVue && etat.sousVue !== cfg.sousVues.defaut) { + out[cfg.sousVues.parametre] = etat.sousVue + } + return out +} + +/** + * Ne garde que les valeurs connues de chaque dimension. Une dimension dont la + * liste de valeurs est encore vide (données pas chargées) garde tout : le + * filtre s'appliquera dès l'arrivée des données, sans perdre le lien partagé. + */ +export function filtresValides(filtres: Record, cfg: ConfigVueFiches): Record { + const out: Record = {} + for (const d of cfg.dimensions) { + const vals = filtres[d.cle] ?? [] + out[d.cle] = d.valeurs.length ? vals.filter((v) => d.valeurs.some((x) => x.valeur === v)) : vals + } + return out +} + +export function nbFiltresActifs(etat: EtatFiches): number { + return Object.values(etat.filtres).reduce((n, v) => n + v.length, 0) + (etat.sansAdresse ? 1 : 0) +} + +// ── Filtrage ─────────────────────────────────────────────────────────────── + +function porte(f: MiniFiche, dimension: string, valeur: string): boolean { + return (f.etiquettes?.[dimension] ?? []).includes(valeur) +} + +export function correspondRecherche(f: MiniFiche, q: string): boolean { + const n = normaliser(q.trim()) + if (!n) return true + return [f.nom, f.ville, f.resume, f.texte].some((t) => !!t && normaliser(t).includes(n)) +} + +/** + * Fiches qui passent la recherche, `adresse=sans` et les filtres de toutes les + * dimensions sauf `ignorer` (sert aux compteurs). OU dans une dimension, ET entre. + */ +export function filtrerFiches( + fiches: MiniFiche[], + etat: EtatFiches, + cfg: ConfigVueFiches, + ignorer?: string, +): MiniFiche[] { + return fiches.filter((f) => { + if (etat.sansAdresse && f.geoloc) return false + if (!correspondRecherche(f, etat.q)) return false + for (const d of cfg.dimensions) { + if (d.cle === ignorer) continue + const vals = etat.filtres[d.cle] ?? [] + if (vals.length && !vals.some((v) => porte(f, d.cle, v))) return false + } + return true + }) +} + +/** + * Compteur de chaque chip : fiches qui portent cette étiquette parmi celles que + * laissent passer la recherche et les AUTRES dimensions. Une chip active affiche + * donc combien de fiches elle apporte, une chip inactive combien on en aurait + * en la cochant seule dans sa dimension ; 0 = chip grisée. + */ +export function compterEtiquettes( + fiches: MiniFiche[], + etat: EtatFiches, + cfg: ConfigVueFiches, +): Record> { + const out: Record> = {} + for (const d of cfg.dimensions) { + const base = filtrerFiches(fiches, etat, cfg, d.cle) + const c: Record = {} + for (const v of d.valeurs) c[v.valeur] = 0 + for (const f of base) for (const v of f.etiquettes?.[d.cle] ?? []) if (v in c) c[v]++ + out[d.cle] = c + } + return out +} + +// ── Tri ──────────────────────────────────────────────────────────────────── + +export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches { + if (etat.tri) return etat.tri + const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0) + if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence' + return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom' +} + +/** Nombre d'étiquettes cochées que porte la fiche. */ +export function pertinence(f: MiniFiche, etat: EtatFiches): number { + let s = 0 + for (const [dim, vals] of Object.entries(etat.filtres)) for (const v of vals) if (porte(f, dim, v)) s++ + return s +} + +const collator = new Intl.Collator('fr', { sensitivity: 'base', numeric: true }) + +function parNom(a: MiniFiche, b: MiniFiche): number { + return collator.compare(a.nom ?? '', b.nom ?? '') +} + +function idNum(f: MiniFiche): number { + const n = Number(f.id) + return Number.isFinite(n) ? n : -Infinity +} + +export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] { + const copie = [...fiches] + if (tri === 'nom') return copie.sort(parNom) + if (tri === 'pertinence') { + const score = new Map(copie.map((f) => [f, pertinence(f, etat)])) + return copie.sort((a, b) => score.get(b)! - score.get(a)! || parNom(a, b)) + } + // récent : date décroissante, puis Id décroissant (Entraide : Id = ordre d'arrivée) + return copie.sort((a, b) => { + if (a.date && b.date && a.date !== b.date) return a.date < b.date ? 1 : -1 + if (!!a.date !== !!b.date) return a.date ? -1 : 1 + return idNum(b) - idNum(a) || parNom(a, b) + }) +} + +// ── Regroupement ─────────────────────────────────────────────────────────── + +/** + * Une fiche va sous sa PREMIÈRE étiquette de la dimension (sinon le compteur + * de groupe ment). Ordre des groupes : celui de `def.valeurs`, sinon de la + * dimension de filtre, sinon A → Z ; « sans » en dernier. L'ordre des fiches + * dans chaque groupe est celui reçu (déjà trié). + */ +export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: ConfigVueFiches): GroupeRendu[] { + if (!def) return [{ cle: '', libelle: '', fiches }] + const ref = def.valeurs ?? cfg.dimensions.find((d) => d.cle === def.dimension)?.valeurs ?? [] + const libelle = (v: string) => ref.find((x) => x.valeur === v)?.libelle ?? v + + const parCle = new Map() + for (const f of fiches) { + const cle = f.etiquettes?.[def.dimension]?.[0] ?? '' + if (!parCle.has(cle)) parCle.set(cle, []) + parCle.get(cle)!.push(f) + } + const rang = (cle: string) => { + if (cle === '') return Number.MAX_SAFE_INTEGER + const i = ref.findIndex((x) => x.valeur === cle) + return i === -1 ? ref.length : i + } + return [...parCle.keys()] + .sort((a, b) => rang(a) - rang(b) || collator.compare(libelle(a), libelle(b))) + .map((cle) => ({ cle, libelle: cle === '' ? def.sans : libelle(cle), fiches: parCle.get(cle)! })) +} + +/** Ordre de lecture courant (filtres + tri + regroupement) : le fiche à fiche de B12 le suit. */ +export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] { + return groupes.flatMap((g) => g.fiches) +}