feat(vue-fiches): état unique lu et écrit dans l'URL, filtres OU/ET, compteurs, tri, regroupement (B11-M1, M2 adaptateurs)

utils/vueFiches.ts porte la logique pure, composables/useVueFiches.ts la branche
sur la route ; MiniFiche gagne etiquettes, coords, date, ville, texte.
Test : node scripts/test-vue-fiches.mjs (42/42).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
This commit is contained in:
Jules Neny
2026-10-02 17:43:43 +02:00
co-authored by Claude Opus 5.5
parent 5ce431d055
commit 38df1d5eb5
4 changed files with 669 additions and 20 deletions
+75 -20
View File
@@ -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<string, string[]>
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 }
}
+165
View File
@@ -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 à
* `<VueFiches :vf="vf">`. 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<MiniFiche[]>,
config: MaybeRefOrGetter<ConfigVueFiches>,
) {
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<typeof setTimeout> | null = null
watch(() => brut.value.q, (v) => {
if (v !== qEcrit) { q.value = v; qEcrit = v }
})
const etat = computed<EtatFiches>(() => ({
...brut.value,
q: q.value,
filtres: filtresValides(brut.value.filtres, cfg.value),
}))
// ── Écriture ──
function ecrire(partiel: Partial<EtatFiches>) {
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<string, string[]> = {}
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<Largeur>(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<EtatVue | 'auto'>(() => {
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<string | number | null>(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<typeof useVueFiches>
+132
View File
@@ -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)
+297
View File
@@ -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<TriFiches, string> = {
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<string, string[]>
/** `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<string, unknown>
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<string, string[]> = {}
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<string, string> {
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<string, string> = {}
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<string, string[]>, cfg: ConfigVueFiches): Record<string, string[]> {
const out: Record<string, string[]> = {}
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<string, Record<string, number>> {
const out: Record<string, Record<string, number>> = {}
for (const d of cfg.dimensions) {
const base = filtrerFiches(fiches, etat, cfg, d.cle)
const c: Record<string, number> = {}
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<string, MiniFiche[]>()
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)
}