/** * The ONE search index — Cmd+K AND Q0 « déjà décidé ? » (same index, by * doctrine): clauses (code + title), decisions (title), mandates (title). * Case- and accent-insensitive. Pure selector over the collective store. * * EXPLICIT imports (no Nuxt auto-imports) — testable under plain vitest. */ import type { Id } from '../types/domain' import { STATUS_LABELS } from '../lexicon' import { useCollectiveStore } from '../stores/collective' export interface SearchHit { kind: 'clause' | 'decision' | 'mandate' id: Id label: string sublabel: string } const MAX_HITS = 20 /** Case/accent-insensitive folding (same folding as the stores). */ function fold(text: string): string { return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() } export function useSearch() { const col = useCollectiveStore() /** Search the unique index. Empty (or blank) query ⇒ no hit. */ const search = (query: string): SearchHit[] => { const needle = fold(query.trim()) if (needle.length === 0) return [] const hits: SearchHit[] = [] for (const clause of col.clauses) { if (hits.length >= MAX_HITS) break if (fold(clause.code).includes(needle) || fold(clause.title).includes(needle)) { const doc = col.docs.find(d => d.id === clause.docId) hits.push({ kind: 'clause', id: clause.id, label: `${clause.code} — ${clause.title}`, sublabel: doc?.title ?? '', }) } } for (const decision of col.decisions) { if (hits.length >= MAX_HITS) break if (fold(decision.title).includes(needle)) { hits.push({ kind: 'decision', id: decision.id, label: decision.title, sublabel: STATUS_LABELS[decision.status], }) } } for (const mandate of col.mandates) { if (hits.length >= MAX_HITS) break if (fold(mandate.title).includes(needle)) { const holder = col.people.find(p => p.id === mandate.holderId) hits.push({ kind: 'mandate', id: mandate.id, label: mandate.title, sublabel: holder?.displayName ?? '', }) } } return hits } return { search } }