From d886302b5906bda10eb63070ed24530ff3a3ad5f Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 11 Aug 2026 10:30:35 +0200 Subject: [PATCH] =?UTF-8?q?v2=20:=20couche=20donn=C3=A9es=20compl=C3=A8te?= =?UTF-8?q?=20+=20shell=20+=20primitives=20communes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stores/collective.ts (état local-first, gabarits, import/export, seeds) + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→ sessions→cristallisation→épreuve du réel→révocation) - data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord, 5 points de départ, Institution symétrique) - composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch (index unique Cmd+K/Q0) - shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/ Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1, LdAvatarStack (premier/second lieu), LdCountdown (suspension striée) - 338 tests vitest verts, npm run build zéro erreur Co-Authored-By: Claude Fable 5 --- frontend/app/app.vue | 739 +--------- .../app/components/common/LdAvatarStack.vue | 100 ++ .../app/components/common/LdCountdown.vue | 52 + .../app/components/common/LdMoodSwitcher.vue | 51 + frontend/app/components/common/LdSeal.vue | 37 + .../components/common/LdWorkspaceSelector.vue | 242 +++ frontend/app/composables/useFeed.ts | 307 ++++ frontend/app/composables/useMood.ts | 16 +- frontend/app/composables/useSearch.ts | 78 + frontend/app/data/templates.ts | 577 ++++++++ frontend/app/engine/index.ts | 21 +- frontend/app/layouts/bare.vue | 39 + frontend/app/layouts/default.vue | 608 ++++++++ frontend/app/stores/collective.ts | 280 ++++ frontend/app/stores/decisions.ts | 1306 ++++++++++++++--- frontend/app/stores/documents.ts | 2 +- frontend/app/stores/organizations.ts | 2 +- frontend/tests/stores/decisions.spec.ts | 607 ++++++++ 18 files changed, 4107 insertions(+), 957 deletions(-) create mode 100644 frontend/app/components/common/LdAvatarStack.vue create mode 100644 frontend/app/components/common/LdCountdown.vue create mode 100644 frontend/app/components/common/LdMoodSwitcher.vue create mode 100644 frontend/app/components/common/LdSeal.vue create mode 100644 frontend/app/components/common/LdWorkspaceSelector.vue create mode 100644 frontend/app/composables/useFeed.ts create mode 100644 frontend/app/composables/useSearch.ts create mode 100644 frontend/app/data/templates.ts create mode 100644 frontend/app/layouts/bare.vue create mode 100644 frontend/app/layouts/default.vue create mode 100644 frontend/app/stores/collective.ts create mode 100644 frontend/tests/stores/decisions.spec.ts diff --git a/frontend/app/app.vue b/frontend/app/app.vue index a8876c6..6f84438 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,740 +1,21 @@ - - diff --git a/frontend/app/components/common/LdAvatarStack.vue b/frontend/app/components/common/LdAvatarStack.vue new file mode 100644 index 0000000..00fa4e3 --- /dev/null +++ b/frontend/app/components/common/LdAvatarStack.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/app/components/common/LdCountdown.vue b/frontend/app/components/common/LdCountdown.vue new file mode 100644 index 0000000..cf79052 --- /dev/null +++ b/frontend/app/components/common/LdCountdown.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/app/components/common/LdMoodSwitcher.vue b/frontend/app/components/common/LdMoodSwitcher.vue new file mode 100644 index 0000000..575176a --- /dev/null +++ b/frontend/app/components/common/LdMoodSwitcher.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/frontend/app/components/common/LdSeal.vue b/frontend/app/components/common/LdSeal.vue new file mode 100644 index 0000000..bbc89b7 --- /dev/null +++ b/frontend/app/components/common/LdSeal.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/app/components/common/LdWorkspaceSelector.vue b/frontend/app/components/common/LdWorkspaceSelector.vue new file mode 100644 index 0000000..bc1eab9 --- /dev/null +++ b/frontend/app/components/common/LdWorkspaceSelector.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/frontend/app/composables/useFeed.ts b/frontend/app/composables/useFeed.ts new file mode 100644 index 0000000..81b16e6 --- /dev/null +++ b/frontend/app/composables/useFeed.ts @@ -0,0 +1,307 @@ +/** + * The Fil (« Aujourd'hui ») — PURE Pinia selectors, ZERO notification table. + * Everything derives from the collective state (BLUEPRINT-V2.md « Navigation » + * COUCHES CODE): windows, votes, due reviews, due reports, boundaries, + * influxes, pending crystallizations, complete dossiers, R6 suggestions. + * + * Every section is a computed array sorted by deadline; myCount sums the + * sections that ask ME for a gesture (the bare counter of « À toi de décider » + * — never red, never a debt word). + * + * EXPLICIT imports (no Nuxt auto-imports) — testable under plain vitest. + */ + +import { computed, type ComputedRef } from 'vue' +import type { + Concern, + Decision, + Id, + Mandate, + Objection, + Protocol, + VoteSession, +} from '../types/domain' +import { TERMINAL_STATUSES } from '../engine' +import { useCollectiveStore } from '../stores/collective' + +export interface SessionFeedItem { + session: VoteSession + decision: Decision | undefined +} + +export interface MandateReportDueItem { + mandate: Mandate + dueAt: string +} + +export type FeedSuggestion = + | { kind: 'claim-mandate'; tags: string[]; count: number } + | { kind: 'protocolize'; tags: string[]; count: number } + | { kind: 'prune-protocol'; protocol: Protocol } + +export interface Feed { + objectionWindows: ComputedRef + adviceRequests: ComputedRef + openVotes: ComputedRef + toCrystallize: ComputedRef + dossiersComplete: ComputedRef + tiesToBreak: ComputedRef + reviewsDue: ComputedRef + mandateReportsDue: ComputedRef + boundaryObjections: ComputedRef + overflowingScopes: ComputedRef + prioritiesAsked: ComputedRef + suggestions: ComputedRef + collectiveActivity: ComputedRef + myCount: ComputedRef +} + +const byDeadline + = (deadline: (d: Decision) => string) => (a: Decision, b: Decision) => + deadline(a) < deadline(b) ? -1 : 1 + +export function useFeed(): Feed { + const col = useCollectiveStore() + + const myId = computed(() => col.me?.id ?? null) + + /** Ids of the decisions where a live Concern names me. */ + const concernedDecisionIds = computed>(() => { + const me = myId.value + const set = new Set() + if (!me) return set + for (const concern of col.concerns) { + if (concern.personId === me) set.add(concern.decisionId) + } + return set + }) + + const isMySteward = (decision: Decision): boolean => { + const me = myId.value + if (!me) return false + if (decision.stewardIds.length > 0) return decision.stewardIds.includes(me) + return decision.authorId === me + } + + const decisionOf = (session: VoteSession): Decision | undefined => + col.decisions.find(d => d.id === session.decisionId) + + // ── Windows where I am concerned ─────────────────────────── + const objectionWindows = computed(() => + col.decisions + .filter(d => d.status === 'objection' && concernedDecisionIds.value.has(d.id)) + .sort(byDeadline(d => d.windowEndsAt ?? '')), + ) + + const adviceRequests = computed(() => + col.decisions + .filter(d => d.status === 'advice' && concernedDecisionIds.value.has(d.id)) + .sort(byDeadline(d => d.windowEndsAt ?? '')), + ) + + // ── Open votes where I belong to the arrested list ───────── + const openVotes = computed(() => { + const me = myId.value + if (!me) return [] + return col.sessions + .filter(s => s.status === 'open' && s.corpusPersonIds.includes(me)) + .sort((a, b) => (a.closesAt < b.closesAt ? -1 : 1)) + .map(session => ({ session, decision: decisionOf(session) })) + }) + + // ── Frozen parametric sessions waiting for MY gesture ────── + const toCrystallize = computed(() => + col.sessions + .filter((s) => { + if (s.status !== 'frozen') return false + const decision = decisionOf(s) + return decision !== undefined && isMySteward(decision) + }) + .sort((a, b) => (a.closesAt < b.closesAt ? -1 : 1)) + .map(session => ({ session, decision: decisionOf(session) })), + ) + + // ── Dossiers whose element children are all terminal ─────── + const dossiersComplete = computed(() => + col.decisions + .filter((d) => { + if (d.status !== 'framing' || !isMySteward(d)) return false + const elements = col.decisions.filter( + child => child.parentDecisionId === d.id && child.chainKind === 'element', + ) + return ( + elements.length > 0 + && elements.every(child => TERMINAL_STATUSES.includes(child.status)) + ) + }) + .sort(byDeadline(d => d.windowEndsAt ?? '')), + ) + + // ── Tied elections waiting for a HUMAN runoff ────────────── + const tiesToBreak = computed(() => + col.sessions + .filter((s) => { + if (s.outcome !== 'tie') return false + const decision = decisionOf(s) + return ( + decision !== undefined && decision.status === 'voting' && isMySteward(decision) + ) + }) + .sort((a, b) => (a.closesAt < b.closesAt ? -1 : 1)) + .map(session => ({ session, decision: decisionOf(session) })), + ) + + // ── Reviews due — « Le réel a-t-il suivi ? » ─────────────── + const reviewsDue = computed(() => { + const me = myId.value + if (!me) return [] + const now = new Date().toISOString() + return col.decisions + .filter((d) => { + if (d.status !== 'adopted' || !d.review || d.review.verdict) return false + if (d.review.dueAt > now) return false + return d.authorId === me || d.stewardIds.includes(me) || d.measurerIds.includes(me) + }) + .sort(byDeadline(d => d.review?.dueAt ?? '')) + }) + + // ── Mandate reports I owe ────────────────────────────────── + const mandateReportsDue = computed(() => { + const me = myId.value + if (!me) return [] + const now = new Date().toISOString() + return col.mandates + .filter(m => m.status === 'active' && m.holderId === me) + .flatMap((mandate) => { + const due = mandate.reports + .filter(r => !r.deliveredAt && r.dueAt <= now) + .sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))[0] + return due ? [{ mandate, dueAt: due.dueAt }] : [] + }) + .sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1)) + }) + + // ── Boundary objections on MY decisions (highest priority) ─ + const boundaryObjections = computed(() => { + const me = myId.value + if (!me) return [] + const mine = new Set(col.decisions.filter(d => d.authorId === me).map(d => d.id)) + return col.objections + .filter(o => o.kind === 'boundary' && o.status === 'open' && mine.has(o.decisionId)) + .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) + }) + + // ── My perimeters overflowing (widen or motivate) ────────── + const overflowingScopes = computed(() => { + const me = myId.value + const settings = col.settings + if (!me || !settings) return [] + const ratio = settings.triage.concernEscalateRatio + return col.decisions + .filter((d) => { + if (d.authorId !== me || d.scopeKeptNote) return false + if (!['objection', 'advice', 'voting'].includes(d.status)) return false + const concerns = col.concerns.filter(c => c.decisionId === d.id) + const computedCount = concerns.filter(c => c.origin === 'computed').length + const declaredCount = concerns.filter(c => c.origin === 'declared').length + return computedCount > 0 && declaredCount >= ratio * computedCount + }) + .sort(byDeadline(d => d.windowEndsAt ?? '')) + }) + + // ── « pondère tes enjeux » — my unweighted element concerns ─ + const prioritiesAsked = computed(() => { + const me = myId.value + if (!me) return [] + const openElements = new Set( + col.decisions + .filter( + d => d.chainKind === 'element' && !TERMINAL_STATUSES.includes(d.status), + ) + .map(d => d.id), + ) + return col.concerns.filter( + c => c.personId === me && c.priority === undefined && openElements.has(c.decisionId), + ) + }) + + // ── R6 / maturation / pruning — simple, never blocking ───── + const suggestions = computed(() => { + const settings = col.settings + if (!settings) return [] + const threshold = settings.triage.recurrenceThreshold + const out: FeedSuggestion[] = [] + + // Group by shared tag (simple heuristic): a tag carried by ≥ threshold + // adopted decisions of the last 90 days suggests a mandate; the same on + // 'record' entries suggests protocolizing the ripe practice. + const since = new Date(Date.now() - 90 * 86_400_000).toISOString() + const countByTag = (rows: Decision[]): Map => { + const map = new Map() + for (const row of rows) { + for (const tag of row.tags) map.set(tag, (map.get(tag) ?? 0) + 1) + } + return map + } + const recent = col.decisions.filter(d => (d.decidedAt ?? d.createdAt) >= since) + for (const [tag, count] of countByTag( + recent.filter(d => d.status === 'adopted' && d.route !== 'record'), + )) { + if (count >= threshold) out.push({ kind: 'claim-mandate', tags: [tag], count }) + } + for (const [tag, count] of countByTag(recent.filter(d => d.route === 'record'))) { + if (count >= threshold) out.push({ kind: 'protocolize', tags: [tag], count }) + } + + // Pruning: a protocol no session ever invoked. + const invoked = new Set(col.sessions.map(s => s.protocolId)) + for (const protocol of col.protocols) { + if (!invoked.has(protocol.id) && protocol.method !== 'consent') { + out.push({ kind: 'prune-protocol', protocol }) + } + } + return out + }) + + // ── The collective's activity (visibility respected) ─────── + const collectiveActivity = computed(() => { + const me = myId.value + return col.decisions + .filter(d => d.visibility !== 'private' || d.authorId === me) + .sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1)) + .slice(0, 20) + }) + + // ── The bare counter — sections that ask ME for a gesture ── + const myCount = computed( + () => + objectionWindows.value.length + + adviceRequests.value.length + + openVotes.value.length + + toCrystallize.value.length + + dossiersComplete.value.length + + tiesToBreak.value.length + + reviewsDue.value.length + + mandateReportsDue.value.length + + boundaryObjections.value.length + + overflowingScopes.value.length + + prioritiesAsked.value.length, + ) + + return { + objectionWindows, + adviceRequests, + openVotes, + toCrystallize, + dossiersComplete, + tiesToBreak, + reviewsDue, + mandateReportsDue, + boundaryObjections, + overflowingScopes, + prioritiesAsked, + suggestions, + collectiveActivity, + myCount, + } +} diff --git a/frontend/app/composables/useMood.ts b/frontend/app/composables/useMood.ts index caab598..7a70ede 100644 --- a/frontend/app/composables/useMood.ts +++ b/frontend/app/composables/useMood.ts @@ -1,7 +1,7 @@ -// Ambiances libreDecision — délègue le mécanisme au layer @yvv/nuxt-base. +// Ambiances libreDecision v2 — champ lexical du puits 井. +// Délègue le mécanisme au layer @yvv/nuxt-base (auto-import `useMood(moods, options)`). // Les couleurs (identité du projet) restent dans assets/css/moods.css (classes .mood-*). -// Le layer fournit l'auto-import `useMood(moods, options)` ; on l'enveloppe ici -// sous le nom `useLibreMood` pour éviter la collision avec ce composable auto-importé. +// Enveloppé sous le nom `useLibreMood` pour éviter la collision avec le composable du layer. export interface Mood { id: string @@ -13,12 +13,12 @@ export interface Mood { } const moods: Mood[] = [ - { id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false }, - { id: 'zen', label: 'Zen', description: 'Nature vivante', icon: 'i-lucide-leaf', color: '#2e8b48', isDark: false }, - { id: 'chagrine', label: 'Chagrine', description: 'Nuit profonde', icon: 'i-lucide-moon', color: '#6488d8', isDark: true }, - { id: 'grave', label: 'Grave', description: 'Ambre mineral', icon: 'i-lucide-shield', color: '#d8a030', isDark: true }, + { id: 'source', label: 'Source', description: 'Eau claire', icon: 'i-lucide-droplets', color: '#0f7fa8', isDark: false }, + { id: 'margelle', label: 'Margelle', description: 'Pierre chaude', icon: 'i-lucide-landmark', color: '#96682a', isDark: false }, + { id: 'nappe', label: 'Nappe', description: 'Eau profonde', icon: 'i-lucide-waves', color: '#3fa9cc', isDark: true }, + { id: 'minuit', label: 'Minuit', description: 'Encre et lanterne', icon: 'i-lucide-lamp', color: '#cf9c3e', isDark: true }, ] export function useLibreMood() { - return useMood(moods, { storageKey: 'libredecision_mood', defaultId: 'peps' }) + return useMood(moods, { storageKey: 'libredecision_mood_v2', defaultId: 'source' }) } diff --git a/frontend/app/composables/useSearch.ts b/frontend/app/composables/useSearch.ts new file mode 100644 index 0000000..fda583e --- /dev/null +++ b/frontend/app/composables/useSearch.ts @@ -0,0 +1,78 @@ +/** + * 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 } +} diff --git a/frontend/app/data/templates.ts b/frontend/app/data/templates.ts new file mode 100644 index 0000000..482cb1a --- /dev/null +++ b/frontend/app/data/templates.ts @@ -0,0 +1,577 @@ +/** + * The seven collective templates (BLUEPRINT-V2.md « Seeds » GABARITS + Δ10/Δ11/Δ20). + * + * buildTemplateBundle() produces a COMPLETE schemaVersion-2 Bundle that goes + * through the SAME import path as a user bundle (importBundle, asSeed:true). + * + * Doctrinal invariants honored here: + * - every template seeds a Consent protocol (protocolByRange.consent — Δ10); + * - every ClauseVersion is born of a Decision: one FOUNDING decision + * (route 'record', adopted) carries every seeded clause version — a single + * shared founding act, exactly what a real constitutive gesture is; + * - blank stays a blank page: preamble + A1 + the « consignation » clause + + * requireEffects='none' + the Consent protocol ONLY — every other setting + * falls back to SETTINGS_DEFAULTS through resolveSettings; + * - the five standard templates (informal/association/cooperative/commune/ + * free-currency) seed the 10 triage setting clauses + Consent, Nuancé, + * Réglage collectif, Élection — 'protocols.large' = the NUANCED protocol + * everywhere EXCEPT free-currency, where large = the inertial binary + * (D30M50B.1G.2 — the Ğ1 heritage put back in its place); + * - free-currency: the default resources unit is 'DU' — v2 keeps it as a + * DESCRIPTIVE default suggested by the tunnel UI (Resources.unit), never a + * conversion: DU and € are oil and water, never summed (huile/eau); + * - symmetric: full transparency (isTransparent), a « Déclaration de + * symétrie » reference TextDoc, and the draft decision « Déclarer notre + * symétrie ». Two MandateDrafts are SUGGESTED by the onboarding UI (not + * seeded — a mandate is born of a decision, never of a template): + * { title: 'Ambassadeur', domainCircleIds: [root], domainTags: ['liaison'], + * durationDays: 180, reportEveryDays: 30 } + * { title: 'Négociateur', domainCircleIds: [root], domainTags: ['symétrie'], + * durationDays: 180, reportEveryDays: 30 } + * - every template proposes the first decision « Adopter notre Pacte » + * (route 'collective', status 'draft', consent protocol). + */ + +import type { + Bundle, + Circle, + Clause, + ClauseVersion, + Collective, + CollectiveTemplate, + Decision, + FormulaParams, + Id, + InertiaPreset, + ISODate, + Json, + Person, + Protocol, + TextDoc, + VoteMethod, +} from '../types/domain' +import { formatModeParams } from '../engine/modeParams' + +export type TemplateId = CollectiveTemplate + +// ───────────────────────────────────────────────────────────── +// The seven cards (/creer, écran 2) — blank FIRST +// ───────────────────────────────────────────────────────────── + +export interface TemplateCard { + id: TemplateId + title: string + subtitle: string + description: string +} + +const STANDARD_SUBTITLE = 'point de départ — tout est amendable par décision' + +export const TEMPLATE_CARDS: TemplateCard[] = [ + { + id: 'blank', + title: 'Page blanche', + subtitle: "observatoire d'abord", + description: + "Une clause : nous consignons nos décisions ; le reste s'écrira au fil de l'eau. " + + 'Consentement seul installé.', + }, + { + id: 'informal', + title: 'Informel', + subtitle: STANDARD_SUBTITLE, + description: + "Un groupe d'amis, un projet naissant : des chemins légers, aucune matière exigée.", + }, + { + id: 'association', + title: 'Association', + subtitle: STANDARD_SUBTITLE, + description: + 'Un Bureau, le consentement et le vote nuancé — la vie associative sans les camps.', + }, + { + id: 'cooperative', + title: 'Coopérative', + subtitle: STANDARD_SUBTITLE, + description: + 'Décider ensemble du travail et des ressources — matière exigée sur ce qui engage.', + }, + { + id: 'commune', + title: 'Commune', + subtitle: STANDARD_SUBTITLE, + description: + 'Des quartiers, des périmètres emboîtés — la subsidiarité en actes.', + }, + { + id: 'free-currency', + title: 'Communauté monnaie libre', + subtitle: STANDARD_SUBTITLE, + description: + "L'héritage Toile de Confiance : le pour/contre inertiel, outil de dernier recours " + + 'des très grands corps.', + }, + { + id: 'symmetric', + title: 'Institution symétrique', + subtitle: 'le geste fondateur', + description: + 'Transparence totale, Déclaration de symétrie, et la première décision : ' + + 'déclarer notre symétrie.', + }, +] + +// ───────────────────────────────────────────────────────────── +// buildTemplateBundle +// ───────────────────────────────────────────────────────────── + +export interface TemplateBuildOptions { + name: string + slug: string + color: string + icon: string + meName: string + memberNames: string[] + now: ISODate + newId: () => Id +} + +/** Which protocols a template installs. */ +const STANDARD_TEMPLATES: TemplateId[] = [ + 'informal', + 'association', + 'cooperative', + 'commune', + 'free-currency', + 'symmetric', +] + +export function buildTemplateBundle(id: TemplateId, opts: TemplateBuildOptions): Bundle { + const { name, slug, color, icon, meName, memberNames, now, newId } = opts + const collectiveId = newId() + + const entity = () => ({ + id: newId(), + collectiveId, + createdAt: now, + updatedAt: now, + }) + + // ── People — me + the initial members ────────────────────── + const me: Person = { ...entity(), displayName: meName, isMe: true } + const members: Person[] = memberNames.map(displayName => ({ + ...entity(), + displayName, + isMe: false, + })) + const people = [me, ...members] + + // ── Circles — root « Tous » + one sober typed circle per template ── + const rootCircle: Circle = { + ...entity(), + name: 'Tous', + purpose: 'Toutes les personnes du collectif', + memberIds: people.map(p => p.id), + domains: [], + } + const circles: Circle[] = [rootCircle] + + const typed: Partial> = { + association: { name: 'Bureau', purpose: "L'équipe qui fait tourner l'association", kind: 'team' }, + cooperative: { name: 'Conseil', purpose: 'Le conseil de la coopérative', kind: 'team' }, + commune: { name: 'Quartiers', purpose: 'Les lieux de la commune', kind: 'place' }, + 'free-currency': { name: 'Forgerons', purpose: 'Celles et ceux qui forgent les blocs', kind: 'team' }, + } + const typedDef = typed[id] + if (typedDef) { + circles.push({ + ...entity(), + name: typedDef.name, + purpose: typedDef.purpose, + kind: typedDef.kind, + memberIds: [], + parentCircleId: rootCircle.id, + domains: [], + }) + } + + // ── Protocols ────────────────────────────────────────────── + const protocols: Protocol[] = [] + const mkProtocol = ( + pName: string, + method: VoteMethod, + description: string, + durationDays: number, + formula: FormulaParams, + ): Protocol => { + const protocol: Protocol = { + ...entity(), + name: pName, + method, + description, + durationDays, + ballot: 'open', + formula, + modeParams: formatModeParams({ + duration_days: durationDays, + majority_pct: formula.majorityPct, + base_exponent: formula.baseExponent, + gradient_exponent: formula.gradientExponent, + constant_base: formula.constantBase, + }), + } + protocols.push(protocol) + return protocol + } + + const baseFormula = { majorityPct: 50, baseExponent: 0.1, gradientExponent: 0.2, constantBase: 0 } + + const consent = mkProtocol( + 'Consentement', + 'consent', + 'Ça me va / J\'objecte — zéro objection maintenue vaut adoption.', + 7, + { ...baseFormula }, + ) + + let nuanced: Protocol | undefined + let parametric: Protocol | undefined + let election: Protocol | undefined + let binary: Protocol | undefined + + if (STANDARD_TEMPLATES.includes(id)) { + nuanced = mkProtocol( + 'Vote nuancé', + 'nuanced', + 'Chacun·e se prononce en nuances, pas en camps.', + 14, + { ...baseFormula, nuancedThresholdPct: 60, nuancedMinParticipants: 3 }, + ) + parametric = mkProtocol( + 'Réglage collectif', + 'parametric', + 'Décider au curseur — le collectif retient la médiane de chaque curseur.', + 14, + { ...baseFormula, parametricMinParticipants: 3 }, + ) + election = mkProtocol( + 'Élection', + 'election', + "Désigner une personne — blanc possible ; en cas d'égalité, vous départagez.", + 14, + { ...baseFormula, electionMinParticipants: 3, tieBreak: 'runoff' }, + ) + } + + if (id === 'free-currency') { + // D30M50B.1G.2 — the exact inherited inertia of the Ğ1 web of trust. + binary = mkProtocol( + 'Vote WoT binaire inertiel', + 'binary', + "l'outil de dernier recours des très grands corps — héritage Toile de Confiance", + 30, + { ...baseFormula }, + ) + } + + // ── The Pact + clauses, each founded by ONE adopted decision ── + const pactDoc: TextDoc = { + ...entity(), + slug: 'pacte', + title: 'Notre Pacte', + role: 'pact', + description: 'Notre contrat social — sacralisé, jamais immuable', + } + const docs: TextDoc[] = [pactDoc] + + const founding: Decision = { + ...entity(), + authorId: me.id, + title: 'Fonder notre Pacte', + tags: ['pacte'], + reversibility: 'costly', + weight: 'structural', + urgent: false, + scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] }, + route: 'record', + triageRule: 'R0a', + routeOverridden: false, + decidedHow: 'posé à la création du collectif — chaque clause reste amendable', + status: 'adopted', + decidedAt: now, + stewardIds: [me.id], + measurerIds: [], + visibility: 'collective', + } + + const clauses: Clause[] = [] + const versions: ClauseVersion[] = [] + let position = 0 + const mkClause = ( + section: string, + code: string, + title: string, + inertia: InertiaPreset, + content: string, + settingKey?: string, + settingValue?: Json, + ): Clause => { + const clause: Clause = { + ...entity(), + docId: pactDoc.id, + section, + position: position++, + code, + title, + inertia, + ...(settingKey !== undefined ? { settingKey } : {}), + } + const version: ClauseVersion = { + ...entity(), + clauseId: clause.id, + decisionId: founding.id, + versionLabel: 'v1', + content, + ...(settingValue !== undefined ? { settingValue } : {}), + status: 'current', + adoptedAt: now, + } + clause.currentVersionId = version.id + clauses.push(clause) + versions.push(version) + return clause + } + + // Preamble P0 — Autonomie–Équilibre–Liaison, proposed and MODIFIABLE. + mkClause( + 'Préambule', + 'P0', + 'Préambule', + 'standard', + 'Autonomie — chacun·e décide de ce qui ne concerne que lui ou elle. ' + + 'Équilibre — ce qui engage le collectif se décide ensemble, à la juste échelle. ' + + 'Liaison — chaque décision garde sa trace et reste révisable. ' + + 'Ce préambule est proposé, jamais imposé : modifiable comme tout le reste.', + ) + + // A1 — Notre finalité (default compasses: vitalité / émancipation). + mkClause( + 'Finalité', + 'A1', + 'Notre finalité', + 'high', + "La vitalité du collectif et l'émancipation de chacun·e sont nos deux boussoles.", + ) + + if (id === 'blank') { + // The one practice clause of the blank page. + mkClause( + 'Pratique', + 'C1', + 'Nous consignons nos décisions', + 'standard', + "Nous consignons nos décisions ; le reste s'écrira au fil de l'eau.", + ) + } + + // ── Setting clauses ──────────────────────────────────────── + const requireEffects = id === 'blank' || id === 'informal' ? 'none' : 'binding' + const requireEffectsContent = + requireEffects === 'none' + ? "Aucune matière exigée — le collectif s'outillera quand la pratique le demandera." + : 'Matière exigée : décisions engageantes et structurantes — au moins un effet recherché avant tout vote collectif.' + + if (id === 'blank') { + // Blank page: requireEffects='none' + the consent protocol, NOTHING else — + // every other setting falls back to SETTINGS_DEFAULTS via resolveSettings. + mkClause('Réglages', 'S1', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', 'none') + mkClause( + 'Réglages', + 'S2', + 'Protocole de consentement', + 'high', + 'Notre protocole de consentement : Consentement (7 jours).', + 'protocols.consent', + consent.id, + ) + } else { + // The 10 triage keys of SETTINGS_DEFAULTS, in clear French. + mkClause('Réglages', 'S1', 'Petit groupe', 'standard', "Jusqu'à 5 personnes, un avis suffit avant de décider.", 'triage.smallGroupMax', 5) + mkClause('Réglages', 'S2', 'Grand collectif', 'standard', 'Au-delà de 50 personnes, la modalité des grands corps s\'applique.', 'triage.collectiveMin', 50) + mkClause('Réglages', 'S3', 'Consentement', 'standard', "Jusqu'à 7 personnes, un tour d'accord suffit : sans objection, c'est adopté.", 'triage.consentMax', 7) + mkClause('Réglages', 'S4', "Fenêtre d'objection", 'standard', "Quarante-huit heures pour dire « Ça me va » ou objecter.", 'triage.objectionWindowHours', 48) + mkClause('Réglages', 'S5', "Fenêtre d'avis", 'standard', 'Soixante-douze heures pour déposer un avis.', 'triage.adviceWindowHours', 72) + mkClause('Réglages', 'S6', 'Formulation', 'standard', "Quatorze jours pour s'instruire et formuler des contre-propositions.", 'triage.framingDays', 14) + mkClause('Réglages', 'S7', 'Débordement du périmètre', 'standard', 'À la moitié de déclarations spontanées, on élargit ou on motive publiquement.', 'triage.concernEscalateRatio', 0.5) + mkClause('Réglages', 'S8', 'Récurrence', 'standard', 'À la troisième décision semblable, réclamer un mandat ou créer une règle.', 'triage.recurrenceThreshold', 3) + mkClause('Réglages', 'S9', "Épreuve du réel", 'standard', "Quatre-vingt-dix jours après l'adoption, le réel a-t-il suivi ?", 'triage.reviewDelayDays', 90) + mkClause('Réglages', 'S10', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', requireEffects) + + // Protocol clauses — the Pact references its protocols (pactClauseId). + const consentClause = mkClause( + 'Réglages', + 'S11', + 'Protocole de consentement', + 'high', + 'Notre protocole de consentement : Consentement (7 jours).', + 'protocols.consent', + consent.id, + ) + consent.pactClauseId = consentClause.id + + if (nuanced) { + const c = mkClause( + 'Réglages', + 'S12', + 'Protocole nuancé', + 'high', + 'De 8 à 50 personnes : le vote nuancé, six nuances, jamais deux camps.', + 'protocols.nuanced', + nuanced.id, + ) + nuanced.pactClauseId = c.id + } + + // 'protocols.large' — nuanced everywhere, inertial binary ONLY here. + const large = id === 'free-currency' ? binary : nuanced + if (large) { + const c = mkClause( + 'Réglages', + 'S13', + 'Modalité des grands corps', + 'max', + id === 'free-currency' + ? 'Au-delà de 50 : le pour/contre inertiel hérité de la Toile de Confiance (D30M50B.1G.2).' + : 'Au-delà de 50 : le vote nuancé, la modalité que notre Pacte a choisie.', + 'protocols.large', + large.id, + ) + if (id === 'free-currency' && binary) binary.pactClauseId = c.id + } + + if (parametric) { + const c = mkClause( + 'Réglages', + 'S14', + 'Réglage collectif', + 'high', + 'Montant, taux, répartition : décider au curseur — le collectif retient la médiane.', + 'protocols.parametric', + parametric.id, + ) + parametric.pactClauseId = c.id + } + if (election) { + const c = mkClause( + 'Réglages', + 'S15', + "Protocole d'élection", + 'high', + "Désigner une personne — blanc possible ; en cas d'égalité, vous départagez, jamais l'outil.", + 'protocols.election', + election.id, + ) + election.pactClauseId = c.id + } + } + + if (id === 'symmetric') { + // Full transparency, declared in the Pact itself. + mkClause( + 'Symétrie', + 'T1', + 'Transparence totale', + 'max', + 'Tout ce que nous décidons est public — la symétrie commence par la transparence.', + ) + docs.push({ + ...entity(), + slug: 'declaration-de-symetrie', + title: 'Déclaration de symétrie', + role: 'reference', + description: + 'Le geste fondateur : notre institution se déclare symétrique de celles qui nous gouvernent.', + }) + } + + // ── Decisions — founding (adopted) + first proposals (draft) ── + const decisions: Decision[] = [founding] + + decisions.push({ + ...entity(), + authorId: me.id, + title: 'Adopter notre Pacte', + body: 'Relire le Pacte proposé par le gabarit, l\'amender si besoin, et l\'adopter ensemble.', + tags: ['pacte'], + reversibility: 'costly', + weight: 'structural', + urgent: false, + scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] }, + route: 'collective', + triageRule: 'R5', + routeOverridden: false, + protocolId: consent.id, + status: 'draft', + stewardIds: [me.id], + measurerIds: [], + visibility: 'collective', + }) + + if (id === 'symmetric') { + decisions.push({ + ...entity(), + authorId: me.id, + title: 'Déclarer notre symétrie', + body: 'Adopter la Déclaration de symétrie et la rendre publique.', + tags: ['symétrie'], + reversibility: 'costly', + weight: 'structural', + urgent: false, + scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] }, + route: 'collective', + triageRule: 'R5', + routeOverridden: false, + protocolId: consent.id, + status: 'draft', + stewardIds: [me.id], + measurerIds: [], + visibility: 'collective', + }) + } + + // ── The tenant ───────────────────────────────────────────── + const collective: Collective = { + id: collectiveId, + slug, + name, + color, + icon, + template: id, + isTransparent: id === 'symmetric' || id === 'free-currency', + pactDocId: pactDoc.id, + rootCircleId: rootCircle.id, + createdAt: now, + updatedAt: now, + } + + return { + schemaVersion: 2, + exportedAt: now, + collective, + people, + circles, + decisions, + concerns: [], + objections: [], + advices: [], + assents: [], + mandates: [], + docs, + clauses, + versions, + protocols, + sessions: [], + votes: [], + } +} diff --git a/frontend/app/engine/index.ts b/frontend/app/engine/index.ts index 41105cb..4d07f09 100644 --- a/frontend/app/engine/index.ts +++ b/frontend/app/engine/index.ts @@ -1,11 +1,16 @@ /** - * Pure voting-formula engine — TypeScript port of backend/app/engine/. - * Python is the reference implementation; the vitest suite in - * frontend/tests/engine/ mirrors the backend pytest suite. + * Pure engine barrel — the SINGLE import surface of app/engine/. + * + * Re-exports EVERYTHING (functions, constants and types) from the eight + * pure modules. Stores and components import from '~/engine' (or relative + * '../engine') and never reach into individual files. */ -export { wotThreshold, smithThreshold, techcommThreshold } from './threshold' -export { nuancedResult, LEVEL_LABELS, NUM_LEVELS } from './nuanced' -export type { NuancedResult } from './nuanced' -export { parseModeParams, formatModeParams } from './modeParams' -export type { ModeParams } from './modeParams' +export * from './threshold' +export * from './nuanced' +export * from './modeParams' +export * from './parametric' +export * from './settings' +export * from './state' +export * from './triage' +export * from './impact' diff --git a/frontend/app/layouts/bare.vue b/frontend/app/layouts/bare.vue new file mode 100644 index 0000000..247df66 --- /dev/null +++ b/frontend/app/layouts/bare.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue new file mode 100644 index 0000000..d275498 --- /dev/null +++ b/frontend/app/layouts/default.vue @@ -0,0 +1,608 @@ + + + + + diff --git a/frontend/app/stores/collective.ts b/frontend/app/stores/collective.ts new file mode 100644 index 0000000..f1694e4 --- /dev/null +++ b/frontend/app/stores/collective.ts @@ -0,0 +1,280 @@ +/** + * Collective store — the tenant: which collective is active, its full local + * state (CollectiveState = Bundle minus export metadata), and the bundle + * lifecycle (create from template, import, export, seeds, delete). + * + * EXPLICIT imports only (no Nuxt auto-imports): the store must run under + * plain vitest + createPinia, and the future FastAPI adapter swaps + * data/persistence without touching any screen. + * + * Getter shortcuts (people, decisions, …) filter soft-deleted rows + * (!archivedAt) — screens never see archived entities; sync keeps them. + */ + +import { defineStore } from 'pinia' +import type { + Advice, + Assent, + Circle, + Clause, + ClauseVersion, + CollectiveSettings, + CollectiveTemplate, + Concern, + Decision, + Id, + ISODate, + Mandate, + Objection, + Person, + Protocol, + TextDoc, + VoteSession, + Vote, +} from '../types/domain' +import type { CollectiveState, ImportResult } from '../data/persistence' +import { + deleteCollective, + getActiveCollectiveId, + importBundle, + listCollectiveIds, + loadState, + saveStateDebounced, + saveStateNow, + setActiveCollectiveId, + toBundle, +} from '../data/persistence' +import { hasConsentProtocol, resolveSettings } from '../engine' +import { buildTemplateBundle } from '../data/templates' +import type { TemplateId } from '../data/templates' + +/** Re-exported for screens: the shape of the active collective's state. */ +export type { CollectiveState } from '../data/persistence' + +export interface CollectiveIndexEntry { + id: Id + slug: string + name: string + color: string + icon: string + template: CollectiveTemplate +} + +export interface CreateFromTemplateOptions { + name: string + slug: string + color: string + icon: string + meName: string + memberNames: string[] +} + +export type SeedName = 'duniter-g1' | 'atelier-du-canal' + +interface CollectiveStoreState { + current: CollectiveState | null + activeId: Id | null + index: CollectiveIndexEntry[] + ready: boolean +} + +function live(rows: T[] | undefined): T[] { + return (rows ?? []).filter(row => !row.archivedAt) +} + +export const useCollectiveStore = defineStore('collective', { + state: (): CollectiveStoreState => ({ + current: null, + activeId: null, + index: [], + ready: false, + }), + + getters: { + /** The local profile — v2 single-seat. */ + me(state): Person | null { + return state.current?.people.find(p => p.isMe && !p.archivedAt) ?? null + }, + + /** Resolved Pact settings — the Pact IS the settings store. */ + settings(state): CollectiveSettings | null { + if (!state.current) return null + const pactDocId = state.current.collective.pactDocId + // Cast: Vue's UnwrapRef over the recursive Json type (settingValue) + // explodes TS instantiation depth; the runtime shape is identical. + const pactClauses = state.current.clauses.filter( + c => c.docId === pactDocId && !c.archivedAt, + ) as unknown as Clause[] + const versions = state.current.versions as unknown as ClauseVersion[] + return resolveSettings(pactClauses, versions) + }, + + hasConsent(): boolean { + const settings = this.settings + return settings !== null && hasConsentProtocol(settings) + }, + + pactDoc(state): TextDoc | null { + if (!state.current) return null + return ( + state.current.docs.find( + d => d.id === state.current!.collective.pactDocId && !d.archivedAt, + ) ?? null + ) + }, + + // ── Non-archived shortcuts — screens never see archived rows ── + people(state): Person[] { return live(state.current?.people) }, + circles(state): Circle[] { return live(state.current?.circles) }, + decisions(state): Decision[] { return live(state.current?.decisions) }, + mandates(state): Mandate[] { return live(state.current?.mandates) }, + docs(state): TextDoc[] { return live(state.current?.docs) }, + clauses(state): Clause[] { return live(state.current?.clauses) }, + versions(state): ClauseVersion[] { + // Cast: same recursive-Json unwrap issue as the settings getter. + return live(state.current?.versions as unknown as ClauseVersion[] | undefined) + }, + protocols(state): Protocol[] { return live(state.current?.protocols) }, + sessions(state): VoteSession[] { return live(state.current?.sessions) }, + votes(state): Vote[] { return live(state.current?.votes) }, + concerns(state): Concern[] { return live(state.current?.concerns) }, + objections(state): Objection[] { return live(state.current?.objections) }, + advices(state): Advice[] { return live(state.current?.advices) }, + assents(state): Assent[] { return live(state.current?.assents) }, + }, + + actions: { + newId(): Id { + return crypto.randomUUID() + }, + + now(): ISODate { + return new Date().toISOString() + }, + + /** LWW clock: bump updatedAt, return the entity. */ + stamp(entity: T): T { + entity.updatedAt = this.now() + return entity + }, + + /** Debounced save of the active collective — every mutation ends here. */ + persist(): void { + if (this.current) saveStateDebounced(this.current as unknown as CollectiveState) + }, + + /** Load the index of local collectives + the active one. */ + async init(): Promise { + const ids = await listCollectiveIds() + const index: CollectiveIndexEntry[] = [] + for (const id of ids) { + const state = await loadState(id) + if (!state) continue + const c = state.collective + index.push({ + id: c.id, slug: c.slug, name: c.name, + color: c.color, icon: c.icon, template: c.template, + }) + } + this.index = index + + const activeId = await getActiveCollectiveId() + if (activeId && ids.includes(activeId)) { + await this.switchTo(activeId) + } + this.ready = true + }, + + async switchTo(id: Id): Promise { + const state = await loadState(id) + if (!state) return + // Cast: assigning into the reactive slot re-triggers the recursive-Json + // UnwrapRef explosion (see the settings getter) — same shape at runtime. + this.current = state as unknown as typeof this.current + this.activeId = id + await setActiveCollectiveId(id) + }, + + /** Create a collective from one of the seven templates — SAME path as an import. */ + async createFromTemplate(id: TemplateId, opts: CreateFromTemplateOptions): Promise { + const bundle = buildTemplateBundle(id, { + ...opts, + now: this.now(), + newId: () => this.newId(), + }) + return await this.importJson(JSON.stringify(bundle), true) + }, + + /** + * Import a bundle (user file or seed). Collision on the collective id is + * REFUSED with a French issue — never a silent overwrite, never an id + * suffix (the id is the sync identity of the collective). + */ + async importJson(json: string, asSeed = false): Promise { + const result = await importBundle(json, { asSeed }) + if (!result.state) return result + if (result.collided) { + return { + issues: [ + ...result.issues, + { + level: 'error', + message: + 'Ce collectif existe déjà sur cette machine — supprime-le d\'abord si tu veux le réimporter.', + }, + ], + collided: true, + } + } + await saveStateNow(result.state) + const c = result.state.collective + this.index.push({ + id: c.id, slug: c.slug, name: c.name, + color: c.color, icon: c.icon, template: c.template, + }) + await this.switchTo(c.id) + return result + }, + + /** Export the active collective as an indented schemaVersion-2 bundle. */ + exportJson(): string | null { + if (!this.current) return null + return JSON.stringify(toBundle(this.current as unknown as CollectiveState, this.now()), null, 2) + }, + + async removeCollective(id: Id): Promise { + await deleteCollective(id) + this.index = this.index.filter(entry => entry.id !== id) + if (this.activeId === id) { + this.activeId = null + this.current = null + } + }, + + /** + * Import a packaged seed (Duniter Ğ1 / Atelier du Canal) through the SAME + * path as a user import. The JSON files are produced by separate tooling: + * a missing file fails SOFTLY with a French issue, never a crash. + */ + async loadSeed(name: SeedName): Promise { + let bundle: unknown + try { + bundle + = name === 'duniter-g1' + ? (await import('../data/seeds/duniter-g1.bundle.json')).default + : (await import('../data/seeds/atelier-du-canal.bundle.json')).default + } catch { + return { + issues: [ + { + level: 'error', + message: `Le jeu de démonstration « ${name} » n'est pas disponible dans cette version.`, + }, + ], + collided: false, + } + } + return await this.importJson(JSON.stringify(bundle), true) + }, + }, +}) diff --git a/frontend/app/stores/decisions.ts b/frontend/app/stores/decisions.ts index 5af4a00..952c0c8 100644 --- a/frontend/app/stores/decisions.ts +++ b/frontend/app/stores/decisions.ts @@ -1,256 +1,1142 @@ /** - * Decisions store: decision processes and their steps. + * Decisions store — the ACTION FAÇADE over collective.current. * - * Maps to the backend /api/v1/decisions endpoints. + * No own state: every read goes through useCollectiveStore(), every mutation + * stamps the entity (LWW updatedAt) and persists (debounced). Actions return + * the mutated object, or { ok: false, reason } with ONE French sentence whose + * subject is the collective or the person — never the engine. + * + * EXPLICIT imports only (no Nuxt auto-imports) — testable under plain vitest. + * + * Mode atelier: the optional `asPersonId` of castVote / assentTo / objectTo / + * adviseOn lets the seated person record someone else's gesture in presence; + * the row then carries recordedById (« saisi par X pour Y »). + * + * DOCUMENTED SIMPLE HEURISTICS (runTriage): + * - matchingClauses: a clause matches when its code (≥2 chars) or its title + * (≥4 chars) appears in the typed title (case/accent-insensitive), when a + * typed #tag equals its code, or when it is the explicitly targeted clause + * (amendsClauseId — R3 needs it in the index); + * - similarRecentCount: adopted decisions of the last 90 days sharing ≥2 tags; + * - similarRecordedCount: 'record' entries of the last 90 days sharing ≥2 tags. + * + * Vote corpus (openSession): computed concerns ∪ declared-before-snapshot + * concerns ∪ THE AUTHOR (the author belongs to the arrested list). */ -export interface DecisionStep { - id: string - decision_id: string - step_order: number - step_type: string - title: string | null - description: string | null - status: string - vote_session_id: string | null - outcome: string | null - created_at: string +import { defineStore } from 'pinia' +import type { + Advice, + Assent, + ClauseVersion, + Concern, + Decision, + DecisionStatus, + Id, + ISODate, + Mandate, + MandateDraft, + NominationMethod, + Objection, + Protocol, + TriageInput, + TriageContext, + Verdict, + VoteMethod, + VoteSession, + Vote, +} from '../types/domain' +import { + canTransition, + computeConcerned, + crystallize as crystallizeVector, + electionResult, + medianByElement, + nuancedResult, + smithThreshold, + techcommThreshold, + triage, + validateVote, + wotThreshold, + type ElectionOutcome, + type NuancedResult, + type TransitionContext, + type TransitionResult, +} from '../engine' +import { sha256Hex } from '../data/persistence' +import { useCollectiveStore } from './collective' + +// ───────────────────────────────────────────────────────────── +// Shared shapes +// ───────────────────────────────────────────────────────────── + +export type Refusal = { ok: false; reason: string } +const refuse = (reason: string): Refusal => ({ ok: false, reason }) + +const NO_COLLECTIVE = 'Aucun collectif actif — ouvre ou crée un collectif d\'abord.' +const NO_DECISION = 'Cette décision est introuvable.' +const NO_SESSION = 'Cette session de vote est introuvable.' + +/** User adjustments applied over the verdict when validating the path. */ +export type ApplyPathEdits = Partial< + Pick< + Decision, + | 'route' | 'scope' | 'reversibility' | 'weight' | 'urgent' | 'protocolId' + | 'title' | 'body' | 'tags' | 'resources' | 'brief' | 'baselineNote' + | 'amendsClauseId' | 'createsMandate' | 'paramSpec' | 'decidedHow' + | 'stewardIds' | 'measurerIds' | 'visibility' | 'overrideNote' | 'sunsetAt' + > +> + +export interface CastVotePayload { + value?: Vote['value'] + values?: number[] + choicePersonId?: Id + comment?: string + /** Mode atelier — record the gesture for someone else (recordedById = me). */ + asPersonId?: Id } -export interface Decision { - id: string +export type TallyResult = + | { method: 'consent'; openObjections: number; assentCount: number; adopted: boolean } + | { + method: 'binary' + votesFor: number + votesAgainst: number + total: number + threshold: number + smithMet: boolean + techcommMet: boolean + adopted: boolean + } + | { method: 'nuanced'; result: NuancedResult; adopted: boolean } + | { method: 'parametric'; median: number[] } + | { method: 'election'; result: ElectionOutcome } + +/** Prefill returned by reopen() for /decider?parent= — NO mutation. */ +export interface ReopenPrefill { + parentDecisionId: Id + chainKind: 'revision' title: string - description: string | null - context: string | null - decision_type: string - status: string - voting_protocol_id: string | null - created_by_id: string | null - created_at: string - updated_at: string - steps: DecisionStep[] + tags: string[] + scope: Decision['scope'] + reversibility: Decision['reversibility'] + weight: Decision['weight'] + protocolId?: Id + amendsClauseId?: Id } -export interface DecisionCreate { - title: string - description?: string | null - context?: string | null - decision_type: string - voting_protocol_id?: string | null +// ───────────────────────────────────────────────────────────── +// Pure helpers +// ───────────────────────────────────────────────────────────── + +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +function addHours(iso: ISODate, hours: number): ISODate { + return new Date(new Date(iso).getTime() + hours * HOUR_MS).toISOString() +} +function addDays(iso: ISODate, days: number): ISODate { + return new Date(new Date(iso).getTime() + days * DAY_MS).toISOString() } -export interface DecisionUpdate { - title?: string - description?: string | null - context?: string | null - decision_type?: string - voting_protocol_id?: string | null +/** Case/accent-insensitive normalization (same folding as useSearch). */ +function fold(text: string): string { + return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() } -export interface DecisionStepCreate { - step_type: string - title?: string | null - description?: string | null +/** #tag extraction — simple: letters, digits, dashes. */ +const TAG_RE = /#([\p{L}\p{N}-]+)/gu +function extractTags(title: string): string[] { + return [...title.matchAll(TAG_RE)].map(m => m[1]!.toLowerCase()) } -interface DecisionsState { - list: Decision[] - current: Decision | null - loading: boolean - error: string | null +function sharedTagCount(a: string[], b: string[]): number { + const set = new Set(a.map(fold)) + return b.reduce((n, tag) => n + (set.has(fold(tag)) ? 1 : 0), 0) } +/** A negative position requires a spoken reason (against, 0, 1). */ +function isNegative(value: Vote['value']): boolean { + return value === 'against' || value === 0 || value === 1 +} + +/** Deep sort object keys — canonical JSON for the engraving fingerprint. */ +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize) + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const key of Object.keys(value as Record).sort()) { + out[key] = canonicalize((value as Record)[key]) + } + return out + } + return value +} + +const NOMINATION_BY_METHOD: Partial> = { + election: 'election-no-candidate', + consent: 'consent', + nuanced: 'nuanced-vote', +} + +// ───────────────────────────────────────────────────────────── +// The store +// ───────────────────────────────────────────────────────────── + export const useDecisionsStore = defineStore('decisions', { - state: (): DecisionsState => ({ - list: [], - current: null, - loading: false, - error: null, - }), - - getters: { - byStatus: (state) => { - return (status: string) => state.list.filter(d => d.status === status) - }, - activeDecisions: (state): Decision[] => { - return state.list.filter(d => - d.status === 'draft' || d.status === 'qualification' || d.status === 'review' || d.status === 'voting', - ) - }, - completedDecisions: (state): Decision[] => { - return state.list.filter(d => d.status === 'executed' || d.status === 'closed') - }, - }, - actions: { - /** - * Fetch all decisions with optional filters. - */ - async fetchAll(params?: { decision_type?: string; status?: string }) { - this.loading = true - this.error = null + // ── Internal accessors ───────────────────────────────────── - try { - const { $api } = useApi() - const query: Record = {} - if (params?.decision_type) query.decision_type = params.decision_type - if (params?.status) query.status = params.status + _col() { + return useCollectiveStore() + }, - this.list = await $api('/decisions/', { query }) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des decisions' - } finally { - this.loading = false + _decision(decisionId: Id): Decision | undefined { + return this._col().decisions.find(d => d.id === decisionId) + }, + + _session(sessionId: Id): VoteSession | undefined { + return this._col().sessions.find(s => s.id === sessionId) + }, + + _protocolOf(session: VoteSession): Protocol | undefined { + return this._col().protocols.find(p => p.id === session.protocolId) + }, + + /** Latest session of a decision (by opensAt). */ + _latestSessionOf(decisionId: Id): VoteSession | undefined { + return this._col() + .sessions.filter(s => s.decisionId === decisionId) + .sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0] + }, + + /** Whether `personId` may perform the steward gestures on this decision. */ + _isSteward(decision: Decision, personId: Id): boolean { + if (decision.stewardIds.length > 0) return decision.stewardIds.includes(personId) + return decision.authorId === personId + }, + + /** Assemble the TransitionContext consumed by engine/state.ts. */ + transitionContext(decisionId: Id): TransitionContext | Refusal { + const col = this._col() + const settings = col.settings + if (!col.current || !settings) return refuse(NO_COLLECTIVE) + const context: TransitionContext = { + concerns: col.concerns, + settings, + children: col.decisions.filter(d => d.parentDecisionId === decisionId), + assents: col.assents.filter(a => a.decisionId === decisionId), + objections: col.objections.filter(o => o.decisionId === decisionId), + now: col.now(), } + const session = this._latestSessionOf(decisionId) + if (session) context.session = session + return context + }, + + // ── ÉMERGENCE — the capture ──────────────────────────────── + + /** One sentence → a draft. selfOnly by default; #tags extracted, simple. */ + capture(title: string): Decision | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const now = col.now() + const decision: Decision = { + id: col.newId(), + collectiveId: col.current.collective.id, + createdAt: now, + updatedAt: now, + authorId: me.id, + title: title.trim(), + tags: extractTags(title), + reversibility: 'easy', + weight: 'light', + urgent: false, + scope: { selfOnly: true, circleIds: [], personIds: [] }, + route: 'solo', + triageRule: 'R2', + routeOverridden: false, + status: 'draft', + stewardIds: [], + measurerIds: [], + visibility: 'private', + } + col.current.decisions.push(decision) + col.persist() + return decision + }, + + // ── TRIAGE — the path ────────────────────────────────────── + + /** Assemble TriageContext from the state and run the pure engine. */ + runTriage(input: TriageInput): Verdict | Refusal { + const col = this._col() + const me = col.me + const settings = col.settings + if (!col.current || !me || !settings) return refuse(NO_COLLECTIVE) + + const activeMandates = col.mandates.filter(m => m.status === 'active') + const title = fold(input.title) + const tagSet = new Set(input.tags.map(fold)) + + const matchingClauses = col.clauses.filter((clause) => { + if (clause.id === input.amendsClauseId) return true // R3 lookup + const code = fold(clause.code) + if (code.length >= 2 && title.includes(code)) return true + const clauseTitle = fold(clause.title) + if (clauseTitle.length >= 4 && title.includes(clauseTitle)) return true + return tagSet.has(code) + }) + + const now = col.now() + const since = addDays(now, -90) + const recent = col.decisions.filter(d => (d.decidedAt ?? d.createdAt) >= since) + const similarRecentCount = recent.filter( + d => d.status === 'adopted' && d.route !== 'record' + && sharedTagCount(d.tags, input.tags) >= 2, + ).length + const similarRecordedCount = recent.filter( + d => d.route === 'record' && sharedTagCount(d.tags, input.tags) >= 2, + ).length + + const ctx: TriageContext = { + myActiveMandates: activeMandates.filter(m => m.holderId === me.id), + otherActiveMandates: activeMandates.filter(m => m.holderId !== me.id), + matchingClauses, + similarRecentCount, + similarRecordedCount, + computedConcernedIds: computeConcerned(input.scope, col.circles, activeMandates, me.id) + .map(entry => entry.personId), + } + return triage(input, ctx, settings) }, /** - * Fetch a single decision by ID with all its steps. + * Validate the path: apply the verdict (then the user's edits) onto the + * decision and move it to the starting status of its route. */ - async fetchById(id: string) { - this.loading = true - this.error = null + applyPath(decision: Decision, verdict: Verdict, edits?: ApplyPathEdits): Decision | Refusal { + const col = this._col() + const me = col.me + const settings = col.settings + if (!col.current || !me || !settings) return refuse(NO_COLLECTIVE) + const now = col.now() - try { - const { $api } = useApi() - this.current = await $api(`/decisions/${id}`) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Decision introuvable' - } finally { - this.loading = false + // Verdict first, user's edits second (the path recommends, never judges). + decision.route = verdict.route + decision.triageRule = verdict.rule + if (verdict.protocolId !== undefined) decision.protocolId = verdict.protocolId + Object.assign(decision, edits) + decision.routeOverridden = edits?.route !== undefined && edits.route !== verdict.route + + if (verdict.windowHours !== undefined) { + decision.windowEndsAt = addHours(now, verdict.windowHours) } - }, - - /** - * Create a new decision. - */ - async create(payload: DecisionCreate) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const decision = await $api('/decisions/', { - method: 'POST', - body: payload, - }) - this.list.unshift(decision) - return decision - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la decision' - throw err - } finally { - this.loading = false + if (verdict.reviewRequired && !decision.review) { + decision.review = { dueAt: addDays(now, settings.triage.reviewDelayDays) } } - }, - /** - * Update an existing decision. - */ - async update(id: string, data: DecisionUpdate) { - this.error = null - - try { - const { $api } = useApi() - const updated = await $api(`/decisions/${id}`, { - method: 'PUT', - body: data, - }) - if (this.current?.id === id) this.current = updated - const idx = this.list.findIndex(d => d.id === id) - if (idx >= 0) this.list[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la mise a jour de la decision' - throw err + // My covering mandate → the decision goes under its spotlights. + if (decision.route === 'mandate') { + const covering = col.mandates.find( + m => + m.status === 'active' + && m.holderId === me.id + && decision.scope.circleIds.length > 0 + && decision.scope.circleIds.every(id => m.domain.circleIds.includes(id)), + ) + if (covering) decision.underMandateId = covering.id } - }, - /** - * Delete a decision. - */ - async delete(id: string) { - this.error = null - - try { - const { $api } = useApi() - await $api(`/decisions/${id}`, { method: 'DELETE' }) - this.list = this.list.filter(d => d.id !== id) - if (this.current?.id === id) this.current = null - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la suppression de la decision' - throw err - } - }, - - /** - * Advance the decision to the next step in its workflow. - */ - async advance(id: string) { - this.error = null - - try { - const { $api } = useApi() - const updated = await $api(`/decisions/${id}/advance`, { - method: 'POST', - }) - if (this.current?.id === id) this.current = updated - const idx = this.list.findIndex(d => d.id === id) - if (idx >= 0) this.list[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'avancement de la decision' - throw err - } - }, - - /** - * Add a step to a decision. - */ - async addStep(id: string, step: DecisionStepCreate) { - this.error = null - - try { - const { $api } = useApi() - const newStep = await $api(`/decisions/${id}/steps`, { - method: 'POST', - body: step, - }) - if (this.current?.id === id) { - this.current.steps.push(newStep) + const createConcerns = () => { + const activeMandates = col.mandates.filter(m => m.status === 'active') + const entries = computeConcerned( + decision.scope, col.circles, activeMandates, decision.authorId, + ) + for (const entry of entries) { + const concern: Concern = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + decisionId: decision.id, + personId: entry.personId, + origin: 'computed', + reason: entry.reason, + beforeSnapshot: true, + } + col.current!.concerns.push(concern) } - return newStep - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape' - throw err + } + + // Conservatory urgency: decided NOW, the collective ratifies (chained). + if (verdict.conservatoryChain) { + createConcerns() + decision.status = 'adopted' + decision.decidedAt = now + const child: Decision = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + authorId: decision.authorId, + title: `Ratifier — ${decision.title}`, + tags: [...decision.tags], + reversibility: decision.reversibility, + weight: decision.weight, + urgent: false, + scope: { + selfOnly: decision.scope.selfOnly, + circleIds: [...decision.scope.circleIds], + personIds: [...decision.scope.personIds], + }, + route: 'collective', + triageRule: 'R-U', + routeOverridden: false, + ...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}), + parentDecisionId: decision.id, + chainKind: 'ratification', + status: 'draft', + stewardIds: [...decision.stewardIds], + measurerIds: [], + visibility: decision.visibility, + } + col.current.decisions.push(child) + col.stamp(decision) + col.persist() + return decision + } + + switch (decision.route) { + case 'solo': + decision.status = 'adopted' + decision.decidedAt = now + break + case 'record': + decision.status = 'adopted' + decision.decidedAt = now + if (edits?.decidedHow !== undefined) decision.decidedHow = edits.decidedHow + break + case 'mandate': + createConcerns() + decision.status = 'objection' + break + case 'advice': + createConcerns() + decision.status = 'advice' + break + case 'collective': { + createConcerns() + if (verdict.framingDays !== undefined) { + decision.status = 'framing' + decision.windowEndsAt = addDays(now, verdict.framingDays) + } else { + const opened = this.openSession(decision) + if ('ok' in opened) return opened + } + break + } + case 'transmit': + decision.status = 'transmitted' + break + } + + col.stamp(decision) + col.persist() + return decision + }, + + // ── The ONE state machine gate ───────────────────────────── + + transition(decisionId: Id, to: DecisionStatus): TransitionResult { + const col = this._col() + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + const ctx = this.transitionContext(decisionId) + if ('ok' in ctx) return ctx + + const result = canTransition(decision, to, ctx) + if (!result.ok) return result + + decision.status = to + if (to === 'adopted') decision.decidedAt = ctx.now + col.stamp(decision) + col.persist() + return result + }, + + // ── Windows: concerns, objections, assents, advices ──────── + + /** « Ça me concerne » — a right, never a request. */ + declareConcern(decisionId: Id, personId: Id, note?: string): Concern | Refusal { + const col = this._col() + if (!col.current) return refuse(NO_COLLECTIVE) + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + const now = col.now() + const concern: Concern = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + decisionId, + personId, + origin: 'declared', + reason: 'se déclare concerné·e', + ...(note !== undefined ? { declaredNote: note } : {}), + // Before the arrested list (no session yet) ⇒ vote; after ⇒ consultative. + beforeSnapshot: !col.sessions.some(s => s.decisionId === decisionId), + } + col.current.concerns.push(concern) + col.persist() + return concern + }, + + /** « J'objecte » — boundary objections SUSPEND the countdown. */ + objectTo( + decisionId: Id, + kind: Objection['kind'], + argument: string, + asPersonId?: Id, + ): Objection | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + if (argument.trim().length === 0) { + return refuse('Une objection s\'argumente — écris pourquoi.') + } + const now = col.now() + const personId = asPersonId ?? me.id + const objection: Objection = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + decisionId, + personId, + kind, + argument: argument.trim(), + status: 'open', + ...(personId !== me.id ? { recordedById: me.id } : {}), + } + col.current.objections.push(objection) + if (kind === 'boundary') { + decision.windowSuspendedAt = now + col.stamp(decision) + } + col.persist() + return objection + }, + + /** « Ça me va » — the stored explicit agreement of objection windows. */ + assentTo(decisionId: Id, asPersonId?: Id): Assent | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + const now = col.now() + const personId = asPersonId ?? me.id + const assent: Assent = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + decisionId, + personId, + ...(personId !== me.id ? { recordedById: me.id } : {}), + } + col.current.assents.push(assent) + col.persist() + return assent + }, + + /** Deposit an advice on an open advice window. */ + adviseOn( + decisionId: Id, + position: Advice['position'], + note?: string, + asPersonId?: Id, + ): Advice | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + const now = col.now() + const personId = asPersonId ?? me.id + const advice: Advice = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + decisionId, + personId, + position, + ...(note !== undefined ? { note } : {}), + ...(personId !== me.id ? { recordedById: me.id } : {}), + } + col.current.advices.push(advice) + col.persist() + return advice + }, + + // ── Sessions & votes ─────────────────────────────────────── + + /** + * Open the vote session: protocol resolved (decision.protocolId, fallback + * consent), corpus ARRESTED = computed ∪ declared-before-snapshot ∪ author. + */ + openSession(decision: Decision): VoteSession | Refusal { + const col = this._col() + const settings = col.settings + if (!col.current || !settings) return refuse(NO_COLLECTIVE) + + const protocolId = decision.protocolId ?? settings.protocolByRange.consent + const protocol = col.protocols.find(p => p.id === protocolId) + if (!protocol) { + return refuse('Aucun protocole — crée-le ou décide sur avis.') + } + + const corpus = new Set([decision.authorId]) + for (const concern of col.concerns) { + if (concern.decisionId !== decision.id) continue + if (concern.origin === 'computed' || concern.beforeSnapshot) { + corpus.add(concern.personId) + } + } + + const now = col.now() + const session: VoteSession = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + decisionId: decision.id, + protocolId: protocol.id, + corpusPersonIds: [...corpus], + corpusSize: corpus.size, + opensAt: now, + closesAt: addDays(now, protocol.durationDays), + status: 'open', + } + col.current.sessions.push(session) + decision.protocolId = protocol.id + decision.status = 'voting' + col.stamp(decision) + col.persist() + return session + }, + + /** Last active votes of a session (supersedes chains resolved). */ + activeVotes(sessionId: Id): Vote[] { + const votes = this._col().votes.filter(v => v.sessionId === sessionId) + const superseded = new Set( + votes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined), + ) + return votes.filter(v => !superseded.has(v.id)) + }, + + /** + * Cast (or replace) a vote. EXACTLY ONE of value / values / choicePersonId + * per method — except the election blank (none of the three). A negative + * position (against, 0, 1) requires a comment. + */ + castVote(sessionId: Id, payload: CastVotePayload): Vote | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const session = this._session(sessionId) + if (!session) return refuse(NO_SESSION) + if (session.status !== 'open') { + return refuse('Cette session n\'accepte plus de vote — les positions sont figées.') + } + const protocol = this._protocolOf(session) + if (!protocol) return refuse('Le protocole de cette session est introuvable.') + + const voterId = payload.asPersonId ?? me.id + if (!session.corpusPersonIds.includes(voterId)) { + return refuse('Tu n\'es pas dans la liste arrêtée — ta voix est consultative.') + } + + const decision = this._decision(session.decisionId) + const provided + = Number(payload.value !== undefined) + + Number(payload.values !== undefined) + + Number(payload.choicePersonId !== undefined) + + switch (protocol.method) { + case 'consent': + return refuse('Le consentement ne se vote pas — dis « Ça me va » ou objecte.') + case 'binary': { + if (provided !== 1 || (payload.value !== 'for' && payload.value !== 'against')) { + return refuse('Ce protocole attend une position pour ou contre — rien d\'autre.') + } + break + } + case 'nuanced': { + const v = payload.value + if (provided !== 1 || typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 5) { + return refuse('Ce protocole attend une nuance de 0 à 5 — rien d\'autre.') + } + break + } + case 'parametric': { + if (provided !== 1 || payload.values === undefined) { + return refuse('Ce protocole attend des positions de curseurs — rien d\'autre.') + } + if (!decision?.paramSpec) { + return refuse('Cette décision n\'a pas de curseurs définis.') + } + try { + validateVote(decision.paramSpec, payload.values) + } catch (error) { + return refuse(error instanceof Error ? error.message : 'Vote invalide.') + } + break + } + case 'election': { + // A deposited vote WITHOUT designation is a BLANK — allowed. + if (provided > 1 || payload.value !== undefined || payload.values !== undefined) { + return refuse('Ce protocole attend une désignation — ou un vote blanc.') + } + if ( + payload.choicePersonId !== undefined + && !session.corpusPersonIds.includes(payload.choicePersonId) + ) { + return refuse('La personne désignée ne fait pas partie de la liste arrêtée.') + } + break + } + } + + if (isNegative(payload.value) && !payload.comment?.trim()) { + return refuse('Dis pourquoi — un commentaire accompagne toute position négative.') + } + + const previous = this.activeVotes(sessionId).find(v => v.voterId === voterId) + const now = col.now() + const vote: Vote = { + id: col.newId(), + collectiveId: session.collectiveId, + createdAt: now, + updatedAt: now, + sessionId, + voterId, + ...(payload.value !== undefined ? { value: payload.value } : {}), + ...(payload.values !== undefined ? { values: payload.values } : {}), + ...(payload.choicePersonId !== undefined + ? { choicePersonId: payload.choicePersonId } + : {}), + ...(payload.comment !== undefined ? { comment: payload.comment } : {}), + ...(previous ? { supersedesVoteId: previous.id } : {}), + ...(voterId !== me.id ? { recordedById: me.id } : {}), + } + col.current.votes.push(vote) + col.persist() + return vote + }, + + /** + * Tally a session from its last active votes — ALWAYS recomputed from + * Vote[], never denormalized. Techcomm heuristic (documented): members of + * a circle whose folded name contains « comite technique ». + */ + tally(session: VoteSession): TallyResult | Refusal { + const col = this._col() + const protocol = this._protocolOf(session) + if (!protocol) return refuse('Le protocole de cette session est introuvable.') + const active = this.activeVotes(session.id) + + switch (protocol.method) { + case 'consent': { + const openObjections = col.objections.filter( + o => o.decisionId === session.decisionId && o.status === 'open', + ).length + const assentCount = col.assents.filter( + a => a.decisionId === session.decisionId, + ).length + return { + method: 'consent', openObjections, assentCount, adopted: openObjections === 0, + } + } + case 'binary': { + const votesFor = active.filter(v => v.value === 'for').length + const votesAgainst = active.filter(v => v.value === 'against').length + const total = votesFor + votesAgainst + const f = protocol.formula + const threshold = wotThreshold( + Math.max(session.corpusSize, 1), total, + f.majorityPct, f.baseExponent, f.gradientExponent, f.constantBase, + ) + let smithMet = true + if (f.smithExponent !== undefined) { + const smithIds = new Set( + col.people.filter(p => p.wotStatus === 'smith').map(p => p.id), + ) + const smithCorpus = session.corpusPersonIds.filter(id => smithIds.has(id)) + if (smithCorpus.length > 0) { + const smithFor = active.filter( + v => v.value === 'for' && smithIds.has(v.voterId), + ).length + smithMet = smithFor >= smithThreshold(smithCorpus.length, f.smithExponent) + } + } + let techcommMet = true + if (f.techcommExponent !== undefined) { + const techCircle = col.circles.find(c => fold(c.name).includes('comite technique')) + const techIds = new Set(techCircle?.memberIds ?? []) + const techCorpus = session.corpusPersonIds.filter(id => techIds.has(id)) + if (techCorpus.length > 0) { + const techFor = active.filter( + v => v.value === 'for' && techIds.has(v.voterId), + ).length + techcommMet = techFor >= techcommThreshold(techCorpus.length, f.techcommExponent) + } + } + return { + method: 'binary', + votesFor, votesAgainst, total, threshold, smithMet, techcommMet, + adopted: votesFor >= threshold && smithMet && techcommMet, + } + } + case 'nuanced': { + const values: number[] = active + .map(v => v.value) + .filter((v): v is Exclude => + typeof v === 'number') + // A formula without an explicit quorum imposes none (fallback 1) — + // the engine's 59 default is a Ğ1 constant, not a template rule. + const result = nuancedResult( + values, + protocol.formula.nuancedThresholdPct ?? 80, + protocol.formula.nuancedMinParticipants ?? 1, + ) + return { method: 'nuanced', result, adopted: result.adopted } + } + case 'parametric': { + const vectors = active + .map(v => v.values) + .filter((v): v is number[] => v !== undefined) + return { method: 'parametric', median: medianByElement(vectors) } + } + case 'election': + return { method: 'election', result: electionResult(active, protocol.formula) } } }, /** - * Create a vote session for a specific step. + * Close a session at its deadline. + * Parametric ⇒ 'frozen' (the steward crystallizes — a HUMAN gesture). + * Election ⇒ automatic, except a tie stays 'tie' (humans break ties). + * Others ⇒ outcome + decision transition + AUTOMATIC application + * (clause version, mandate creation, parent revocation). */ - async createVoteSession(decisionId: string, stepId: string) { - this.error = null - - try { - const { $api } = useApi() - const result = await $api(`/decisions/${decisionId}/steps/${stepId}/create-vote-session`, { - method: 'POST', - }) - // Refresh decision to get updated step with vote_session_id - await this.fetchById(decisionId) - return result - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la session de vote' - throw err + closeSession(session: VoteSession): VoteSession | Refusal { + const col = this._col() + if (!col.current) return refuse(NO_COLLECTIVE) + if (session.status !== 'open') { + return refuse('Cette session est déjà close ou figée.') } + const protocol = this._protocolOf(session) + if (!protocol) return refuse('Le protocole de cette session est introuvable.') + const decision = this._decision(session.decisionId) + if (!decision) return refuse(NO_DECISION) + + if (protocol.method === 'parametric') { + session.status = 'frozen' + col.stamp(session) + col.persist() + return session + } + + const tallied = this.tally(session) + if ('ok' in tallied) return tallied + + if (tallied.method === 'election') { + session.status = 'closed' + const res = tallied.result + if (res.outcome === 'tie') { + session.outcome = 'tie' // the tool NEVER breaks a tie + } else if (res.outcome === 'elected') { + session.outcome = 'adopted' + const moved = this.transition(decision.id, 'adopted') + if (moved.ok) this._applyAdoption(decision, res.winnerId) + } else { + session.outcome = 'rejected' + this.transition(decision.id, 'rejected') + } + col.stamp(session) + col.persist() + return session + } + + const adopted = tallied.method === 'parametric' ? false : tallied.adopted + session.status = 'closed' + session.outcome = adopted ? 'adopted' : 'rejected' + const moved = this.transition(decision.id, adopted ? 'adopted' : 'rejected') + if (adopted && moved.ok) this._applyAdoption(decision) + col.stamp(session) + col.persist() + return session }, /** - * Clear the current decision. + * Automatic application at adoption — zero manual acceptance button: + * - amendsClauseId ⇒ the linked ClauseVersion becomes 'current', the old + * one 'superseded' (the proposed version of this decision if it exists, + * otherwise a version created from the decision body); + * - createsMandate ⇒ an active Mandate born of this decision; + * - chainKind 'revocation' ⇒ the parent decision moves to 'revoked'. */ - clearCurrent() { - this.current = null + _applyAdoption(decision: Decision, holderId?: Id): void { + const col = this._col() + if (!col.current) return + const now = col.now() + + if (decision.amendsClauseId) { + const clause = col.clauses.find(c => c.id === decision.amendsClauseId) + if (clause) { + const clauseVersions = col.versions.filter(v => v.clauseId === clause.id) + let adoptedVersion = clauseVersions.find( + v => v.decisionId === decision.id && v.status === 'proposed', + ) + if (adoptedVersion) { + adoptedVersion.status = 'current' + adoptedVersion.adoptedAt = now + col.stamp(adoptedVersion) + } else { + adoptedVersion = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + clauseId: clause.id, + decisionId: decision.id, + versionLabel: `v${clauseVersions.length + 1}`, + content: decision.body ?? decision.title, + status: 'current', + adoptedAt: now, + } satisfies ClauseVersion + // Cast: pushing into the reactive array re-triggers the + // recursive-Json UnwrapRef explosion (settingValue) — same shape. + ;(col.current.versions as unknown as ClauseVersion[]).push(adoptedVersion) + } + for (const version of clauseVersions) { + if (version.id !== adoptedVersion.id && version.status === 'current') { + version.status = 'superseded' + col.stamp(version) + } + } + clause.currentVersionId = adoptedVersion.id + col.stamp(clause) + } + } + + if (decision.createsMandate) { + const draft: MandateDraft = decision.createsMandate + const protocol = col.protocols.find(p => p.id === decision.protocolId) + const mandate: Mandate = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + title: draft.title, + holderId: holderId ?? decision.authorId, + originDecisionId: decision.id, + domain: { circleIds: [...draft.domainCircleIds], tags: [...draft.domainTags] }, + startsAt: now, + endsAt: addDays(now, draft.durationDays), + electorCircleId: + decision.scope.circleIds[0] ?? col.current.collective.rootCircleId, + nominationMethod: + (protocol && NOMINATION_BY_METHOD[protocol.method]) ?? 'ratified-self', + reports: draft.reportEveryDays !== undefined + ? [{ dueAt: addDays(now, draft.reportEveryDays) }] + : [], + status: 'active', + } + col.current.mandates.push(mandate) + } + + if (decision.chainKind === 'revocation' && decision.parentDecisionId) { + this.transition(decision.parentDecisionId, 'revoked') + } + + col.persist() + }, + + /** + * THE GESTURE — the steward crystallizes a frozen parametric session: + * dated, signed, never automatic. Quorum below parametricMinParticipants + * ⇒ rejected, observed at the same gesture. + */ + crystallize(sessionId: Id): VoteSession | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const session = this._session(sessionId) + if (!session) return refuse(NO_SESSION) + if (session.status !== 'frozen') { + return refuse('Les votes ne sont pas figés — la cristallisation attend la clôture.') + } + const decision = this._decision(session.decisionId) + if (!decision?.paramSpec) return refuse(NO_DECISION) + if (!this._isSteward(decision, me.id)) { + return refuse('Seul·e un·e garant·e de cette décision peut cristalliser.') + } + const protocol = this._protocolOf(session) + if (!protocol) return refuse('Le protocole de cette session est introuvable.') + + const now = col.now() + const vectors = this.activeVotes(sessionId) + .map(v => v.values) + .filter((v): v is number[] => v !== undefined) + + session.crystallizedById = me.id + session.crystallizedAt = now + session.status = 'closed' + + const quorum = protocol.formula.parametricMinParticipants + if (quorum !== undefined && vectors.length < quorum) { + session.outcome = 'rejected' + col.stamp(session) + this.transition(decision.id, 'rejected') + col.persist() + return session + } + + const position = crystallizeVector(decision.paramSpec, vectors) + const readable = decision.paramSpec.params + .map((p, i) => `${p.label} : ${position[i] ?? '—'}${p.unit ? ` ${p.unit}` : ''}`) + .join(' · ') + decision.body + = `${decision.body ? `${decision.body}\n\n` : ''}Position cristallisée : ${readable}` + + session.outcome = 'adopted' + col.stamp(session) + col.stamp(decision) + const moved = this.transition(decision.id, 'adopted') + if (moved.ok) this._applyAdoption(decision) + col.persist() + return session + }, + + // ── L'épreuve du réel ────────────────────────────────────── + + /** + * « Ça tient / À revoir / À révoquer ». 'confirmed' closes the review + * (a one-shot decision — sunset already past — moves to 'closed'); + * 'revise'/'revoke' pre-fills a chained child under the ORIGINAL protocol. + */ + reviewVerdict( + decisionId: Id, + verdict: 'confirmed' | 'revise' | 'revoke', + note?: string, + ): Decision | Refusal { + const col = this._col() + const me = col.me + if (!col.current || !me) return refuse(NO_COLLECTIVE) + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + if (!decision.review) { + return refuse('Cette décision n\'a pas d\'épreuve du réel posée.') + } + const now = col.now() + decision.review.verdict = verdict + if (note !== undefined) decision.review.note = note + decision.review.decidedAt = now + col.stamp(decision) + + if (verdict === 'confirmed') { + if (decision.sunsetAt !== undefined && decision.sunsetAt <= now) { + this.transition(decision.id, 'closed') + } + col.persist() + return decision + } + + const chainKind = verdict === 'revise' ? 'revision' : 'revocation' + const child: Decision = { + id: col.newId(), + collectiveId: decision.collectiveId, + createdAt: now, + updatedAt: now, + authorId: me.id, + title: `${verdict === 'revise' ? 'Réviser' : 'Révoquer'} — ${decision.title}`, + tags: [...decision.tags], + reversibility: decision.reversibility, + weight: decision.weight, + urgent: false, + scope: { + selfOnly: decision.scope.selfOnly, + circleIds: [...decision.scope.circleIds], + personIds: [...decision.scope.personIds], + }, + route: 'collective', + triageRule: decision.triageRule, + routeOverridden: false, + ...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}), + ...(chainKind === 'revision' && decision.amendsClauseId !== undefined + ? { amendsClauseId: decision.amendsClauseId } + : {}), + parentDecisionId: decision.id, + chainKind, + status: 'draft', + stewardIds: [...decision.stewardIds], + measurerIds: [...decision.measurerIds], + visibility: decision.visibility, + } + col.current.decisions.push(child) + col.persist() + return child + }, + + /** « Remettre en question » — prefill for /decider?parent=, NO mutation. */ + reopen(decisionId: Id): ReopenPrefill | Refusal { + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + return { + parentDecisionId: decision.id, + chainKind: 'revision', + title: decision.title, + tags: [...decision.tags], + scope: { + selfOnly: decision.scope.selfOnly, + circleIds: [...decision.scope.circleIds], + personIds: [...decision.scope.personIds], + }, + reversibility: decision.reversibility, + weight: decision.weight, + ...(decision.protocolId !== undefined ? { protocolId: decision.protocolId } : {}), + ...(decision.amendsClauseId !== undefined + ? { amendsClauseId: decision.amendsClauseId } + : {}), + } + }, + + // ── Preuve ───────────────────────────────────────────────── + + /** « empreinte locale — démo » : sha256 of the canonical decision JSON. */ + async engrave(decisionId: Id): Promise { + const col = this._col() + if (!col.current) return refuse(NO_COLLECTIVE) + const decision = this._decision(decisionId) + if (!decision) return refuse(NO_DECISION) + const { engraving: _previous, ...fingerprintable } = decision + const canonical = JSON.stringify(canonicalize(fingerprintable)) + decision.engraving = { + sha256: await sha256Hex(canonical), + engravedAt: col.now(), + proofLevel: 'local', + } + col.stamp(decision) + col.persist() + return decision }, }, }) diff --git a/frontend/app/stores/documents.ts b/frontend/app/stores/documents.ts index c61aef5..6d28214 100644 --- a/frontend/app/stores/documents.ts +++ b/frontend/app/stores/documents.ts @@ -265,7 +265,7 @@ export const useDocumentsStore = defineStore('documents', { const map: Record = {} itemIds.forEach((id, i) => { const r = results[i] - map[id] = r.status === 'fulfilled' ? r.value : [] + map[id] = r?.status === 'fulfilled' ? r.value : [] }) this.allItemVersions = map } finally { diff --git a/frontend/app/stores/organizations.ts b/frontend/app/stores/organizations.ts index fa0ad77..9a3a688 100644 --- a/frontend/app/stores/organizations.ts +++ b/frontend/app/stores/organizations.ts @@ -49,7 +49,7 @@ export const useOrganizationsStore = defineStore('organizations', { const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null if (stored && this.organizations.some(o => o.slug === stored)) { this.activeSlug = stored - } else if (this.organizations.length > 0) { + } else if (this.organizations[0]) { this.activeSlug = this.organizations[0].slug } } catch (err: any) { diff --git a/frontend/tests/stores/decisions.spec.ts b/frontend/tests/stores/decisions.spec.ts new file mode 100644 index 0000000..151c6c1 --- /dev/null +++ b/frontend/tests/stores/decisions.spec.ts @@ -0,0 +1,607 @@ +/** + * Store-layer specs — the decisions façade + the seven templates. + * + * Plain vitest + createPinia (no Nuxt runtime): the stores import defineStore + * from 'pinia' and the engines/persistence relatively, so they run here as-is. + * idb-keyval is mocked with an in-memory Map. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('idb-keyval', () => { + const store = new Map() + return { + get: async (key: string) => store.get(key), + set: async (key: string, value: unknown) => void store.set(key, value), + del: async (key: string) => void store.delete(key), + keys: async () => [...store.keys()], + } +}) + +import { useCollectiveStore } from '../../app/stores/collective' +import { useDecisionsStore } from '../../app/stores/decisions' +import type { Refusal } from '../../app/stores/decisions' +import { buildTemplateBundle, TEMPLATE_CARDS } from '../../app/data/templates' +import type { TemplateId } from '../../app/data/templates' +import { validateBundle } from '../../app/data/persistence' +import { hasConsentProtocol, resolveSettings, windowOutcome } from '../../app/engine' +import type { + Decision, + Mandate, + ParamSpec, + Verdict, + VoteSession, + Vote, +} from '../../app/types/domain' + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +const MEMBERS = ['Alice', 'Bakir', 'Chloé', 'Dara'] + +function isRefusal(value: unknown): value is Refusal { + return ( + typeof value === 'object' + && value !== null + && 'ok' in value + && (value as { ok: unknown }).ok === false + ) +} + +/** Narrow an `X | Refusal` return — throws the French reason on refusal. */ +function ok(value: T | Refusal): T { + if (isRefusal(value)) throw new Error(value.reason) + return value +} + +async function setup(template: TemplateId = 'association') { + setActivePinia(createPinia()) + const col = useCollectiveStore() + const store = useDecisionsStore() + const result = await col.createFromTemplate(template, { + name: 'Le Test', + slug: `test-${crypto.randomUUID().slice(0, 8)}`, + color: '#3b82f6', + icon: 'i-lucide-users', + meName: 'Yvv', + memberNames: MEMBERS, + }) + expect(result.state).toBeDefined() + const me = col.me! + const root = col.circles.find(c => c.id === col.current!.collective.rootCircleId)! + const person = (name: string) => col.people.find(p => p.displayName === name)! + const protocolByMethod = (method: string) => + col.protocols.find(p => p.method === method)! + return { col, store, me, root, person, protocolByMethod } +} + +const buildOpts = (slug: string) => ({ + name: 'Gabarit', + slug, + color: '#0ea5e9', + icon: 'i-lucide-users', + meName: 'Yvv', + memberNames: MEMBERS, + now: '2026-08-11T10:00:00.000Z', + newId: () => crypto.randomUUID(), +}) + +// ───────────────────────────────────────────────────────────── +// Solo — 2 gestures, adopted +// ───────────────────────────────────────────────────────────── + +describe('solo path', () => { + it('capture → triage → applyPath adopts in two mutations', async () => { + const { store } = await setup() + const decision = ok(store.capture('Acheter une bouilloire pour le local')) + expect(decision.status).toBe('draft') + expect(decision.scope.selfOnly).toBe(true) + + const verdict = ok( + store.runTriage({ + title: decision.title, + tags: decision.tags, + scope: decision.scope, + reversibility: 'easy', + weight: 'light', + urgent: false, + }), + ) as Verdict + expect(verdict.route).toBe('solo') + expect(verdict.rule).toBe('R2') + + const adopted = ok(store.applyPath(decision, verdict)) + expect(adopted.status).toBe('adopted') + expect(adopted.decidedAt).toBeDefined() + expect(adopted.windowEndsAt).toBeUndefined() + }) + + it('extracts #tags at capture — simple', async () => { + const { store } = await setup() + const decision = ok(store.capture('Réparer le vidéoprojecteur #matériel #salle')) + expect(decision.tags).toEqual(['matériel', 'salle']) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Mandate — window, Assent, windowOutcome +// ───────────────────────────────────────────────────────────── + +describe('mandate path', () => { + it('routes under my covering mandate, opens the window, adopts on explicit assent', async () => { + const { col, store, me, root, person } = await setup() + const now = col.now() + const mandate: Mandate = { + id: col.newId(), + collectiveId: col.current!.collective.id, + createdAt: now, + updatedAt: now, + title: 'Intendance du local', + holderId: me.id, + originDecisionId: col.newId(), + domain: { circleIds: [root.id], tags: [] }, + startsAt: now, + endsAt: '2100-01-01T00:00:00.000Z', + electorCircleId: root.id, + nominationMethod: 'consent', + reports: [], + status: 'active', + } + col.current!.mandates.push(mandate) + + const decision = ok(store.capture('Remplacer la serrure du local')) + decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] } + const verdict = ok( + store.runTriage({ + title: decision.title, + tags: decision.tags, + scope: decision.scope, + reversibility: 'costly', + weight: 'binding', + urgent: false, + }), + ) as Verdict + expect(verdict.route).toBe('mandate') + expect(verdict.rule).toBe('R0b') + expect(verdict.windowHours).toBe(48) + + // The tunnel's chips travel through edits (the verdict carries none). + ok(store.applyPath(decision, verdict, { reversibility: 'costly', weight: 'binding' })) + expect(decision.status).toBe('objection') + expect(decision.underMandateId).toBe(mandate.id) + expect(decision.windowEndsAt).toBeDefined() + // Computed concerns: the four members (the author is excluded). + const concerns = col.concerns.filter(c => c.decisionId === decision.id) + expect(concerns).toHaveLength(4) + expect(concerns.every(c => c.origin === 'computed' && c.beforeSnapshot)).toBe(true) + + // Costly ⇒ never adopted by pure silence: extend, until a third-party assent. + const ctx = ok(store.transitionContext(decision.id)) + expect(windowOutcome(decision, ctx)).toBe('extend') + + ok(store.assentTo(decision.id, person('Alice').id)) + const ctxAfter = ok(store.transitionContext(decision.id)) + expect(windowOutcome(decision, ctxAfter)).toBe('adopt') + + const moved = store.transition(decision.id, 'adopted') + expect(moved.ok).toBe(true) + expect(decision.status).toBe('adopted') + }) +}) + +// ───────────────────────────────────────────────────────────── +// Collective consent — objection blocks adoption +// ───────────────────────────────────────────────────────────── + +describe('collective consent path', () => { + it('opens a session, and a maintained objection prevents adoption', async () => { + const { col, store, root, person } = await setup() + const decision = ok(store.capture('Organiser une fête de quartier au local')) + decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] } + const verdict = ok( + store.runTriage({ + title: decision.title, + tags: decision.tags, + scope: decision.scope, + reversibility: 'costly', + weight: 'light', + urgent: false, + }), + ) as Verdict + expect(verdict.route).toBe('collective') + expect(verdict.rule).toBe('R5') + + ok(store.applyPath(decision, verdict)) + expect(decision.status).toBe('voting') + const session = col.sessions.find(s => s.decisionId === decision.id)! + // Arrested list: 4 computed concerned + the author. + expect(session.corpusSize).toBe(5) + expect(session.status).toBe('open') + + ok(store.objectTo(decision.id, 'content', 'La cour est trop petite.', person('Bakir').id)) + const closed = ok(store.closeSession(session)) + expect(closed.outcome).toBe('rejected') + expect(decision.status).not.toBe('adopted') + }) + + it('boundary objection suspends the window countdown', async () => { + const { store, person } = await setup() + const decision = ok(store.capture('Changer le planning du ménage')) + decision.status = 'objection' + ok(store.objectTo(decision.id, 'boundary', 'Le cercle Cuisine a été oublié.', person('Chloé').id)) + expect(decision.windowSuspendedAt).toBeDefined() + const refused = store.transition(decision.id, 'adopted') + expect(refused.ok).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Nuanced — full loop, clause application +// ───────────────────────────────────────────────────────────── + +describe('nuanced session', () => { + it('votes, tallies, closes and applies the amended clause automatically', async () => { + const { col, store, root, person, protocolByMethod } = await setup() + const nuanced = protocolByMethod('nuanced') + const clauseA1 = col.clauses.find(c => c.code === 'A1')! + const oldVersionId = clauseA1.currentVersionId! + + const decision = ok(store.capture('Donner une nouvelle boussole au collectif')) + decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] } + const verdict = ok( + store.runTriage({ + title: decision.title, + tags: decision.tags, + scope: decision.scope, + reversibility: 'costly', + weight: 'light', + urgent: false, + }), + ) as Verdict + ok( + store.applyPath(decision, verdict, { + protocolId: nuanced.id, + amendsClauseId: clauseA1.id, + body: 'La convivialité rejoint nos deux boussoles.', + }), + ) + expect(decision.status).toBe('voting') + const session = col.sessions.find(s => s.decisionId === decision.id)! + + // A negative nuance without comment is refused. + const refused = store.castVote(session.id, { value: 1 }) + expect(isRefusal(refused)).toBe(true) + + ok(store.castVote(session.id, { value: 4 })) + ok(store.castVote(session.id, { value: 5, asPersonId: person('Alice').id })) + ok(store.castVote(session.id, { value: 3, asPersonId: person('Bakir').id })) + + const tallied = ok(store.tally(session)) + if (tallied.method !== 'nuanced') throw new Error('bad method') + expect(tallied.result.total).toBe(3) + expect(tallied.result.adopted).toBe(true) + + const closed = ok(store.closeSession(session)) + expect(closed.outcome).toBe('adopted') + expect(decision.status).toBe('adopted') + + // Automatic application: new current version, old one superseded. + const newVersion = col.versions.find( + v => v.clauseId === clauseA1.id && v.status === 'current', + )! + expect(newVersion.decisionId).toBe(decision.id) + expect(newVersion.content).toBe('La convivialité rejoint nos deux boussoles.') + expect(clauseA1.currentVersionId).toBe(newVersion.id) + expect(col.versions.find(v => v.id === oldVersionId)!.status).toBe('superseded') + }) + + it('re-vote supersedes — only the last active vote counts', async () => { + const { col, store, root, protocolByMethod } = await setup() + const decision = ok(store.capture('Peindre la façade en ocre')) + decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] } + const verdict = ok( + store.runTriage({ + title: decision.title, tags: [], scope: decision.scope, + reversibility: 'costly', weight: 'light', urgent: false, + }), + ) as Verdict + ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('nuanced').id })) + const session = col.sessions.find(s => s.decisionId === decision.id)! + + const first = ok(store.castVote(session.id, { value: 2 })) as Vote + const second = ok(store.castVote(session.id, { value: 5 })) as Vote + expect(second.supersedesVoteId).toBe(first.id) + const active = store.activeVotes(session.id) + expect(active).toHaveLength(1) + expect(active[0]!.value).toBe(5) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Parametric — frozen, then the steward's gesture +// ───────────────────────────────────────────────────────────── + +describe('parametric session', () => { + const spec: ParamSpec = { + params: [ + { key: 'ateliers', label: 'Ateliers', kind: 'share', min: 0, max: 100, step: 5, baseline: 40 }, + { key: 'materiel', label: 'Matériel', kind: 'share', min: 0, max: 100, step: 5, baseline: 30 }, + { key: 'reserve', label: 'Réserve', kind: 'share', min: 0, max: 100, step: 5, derived: true }, + ], + constraint: 'sum100', + } + + async function openParametric() { + const context = await setup() + const { col, store, root, protocolByMethod, person } = context + const decision = ok(store.capture('Répartir le budget des ateliers')) + decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] } + const verdict = ok( + store.runTriage({ + title: decision.title, tags: [], scope: decision.scope, + reversibility: 'costly', weight: 'light', urgent: false, + }), + ) as Verdict + ok( + store.applyPath(decision, verdict, { + protocolId: protocolByMethod('parametric').id, + paramSpec: spec, + stewardIds: [person('Alice').id], + }), + ) + const session = col.sessions.find(s => s.decisionId === decision.id)! + return { ...context, decision, session } + } + + it('validates votes, freezes at closure, refuses a non-steward, crystallizes by gesture', async () => { + const { store, person, me, decision, session } = await openParametric() + + // Off-grid vote refused (step 5). + const bad = store.castVote(session.id, { values: [3, 30] }) + expect(isRefusal(bad)).toBe(true) + + ok(store.castVote(session.id, { values: [50, 30] })) + ok(store.castVote(session.id, { values: [40, 40], asPersonId: person('Alice').id })) + ok(store.castVote(session.id, { values: [60, 20], asPersonId: person('Bakir').id })) + + // Parametric NEVER closes automatically: it freezes and waits. + const frozen = ok(store.closeSession(session)) + expect(frozen.status).toBe('frozen') + expect(decision.status).toBe('voting') + + // I am not a steward (Alice is) — the gesture is refused. + const refused = store.crystallize(session.id) + expect(isRefusal(refused) && refused.reason.includes('garant')).toBe(true) + + // As a steward, the dated human gesture crystallizes the LOW median. + decision.stewardIds = [me.id] + const closed = ok(store.crystallize(session.id)) + expect(closed.status).toBe('closed') + expect(closed.outcome).toBe('adopted') + expect(closed.crystallizedById).toBe(me.id) + expect(closed.crystallizedAt).toBeDefined() + expect(decision.status).toBe('adopted') + // Low medians: Ateliers 50, Matériel 30, Réserve derived 20. + expect(decision.body).toContain('Position cristallisée') + expect(decision.body).toContain('Ateliers : 50') + expect(decision.body).toContain('Réserve : 20') + }) + + it('quorum not reached ⇒ rejected, observed at the same gesture', async () => { + const { store, me, decision, session } = await openParametric() + ok(store.castVote(session.id, { values: [50, 30] })) // 1 < parametricMinParticipants 3 + ok(store.closeSession(session)) + decision.stewardIds = [me.id] + const closed = ok(store.crystallize(session.id)) + expect(closed.outcome).toBe('rejected') + expect(decision.status).toBe('rejected') + }) +}) + +// ───────────────────────────────────────────────────────────── +// Election — blank votes, tie left to humans +// ───────────────────────────────────────────────────────────── + +describe('election session', () => { + it('accepts blanks, and a tie is NEVER broken by the tool', async () => { + const { col, store, root, person, protocolByMethod } = await setup() + const decision = ok(store.capture('Confier la trésorerie')) + decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] } + const verdict = ok( + store.runTriage({ + title: decision.title, tags: [], scope: decision.scope, + reversibility: 'costly', weight: 'light', urgent: false, + }), + ) as Verdict + ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('election').id })) + const session = col.sessions.find(s => s.decisionId === decision.id)! + + ok(store.castVote(session.id, { choicePersonId: person('Alice').id })) + ok(store.castVote(session.id, { + choicePersonId: person('Chloé').id, asPersonId: person('Bakir').id, + })) + // A deposited vote WITHOUT designation is a BLANK — participation only. + const blank = ok(store.castVote(session.id, { asPersonId: person('Dara').id })) as Vote + expect(blank.choicePersonId).toBeUndefined() + + const closed = ok(store.closeSession(session)) + expect(closed.outcome).toBe('tie') + // The decision waits for the HUMAN runoff — never adopted by the engine. + expect(decision.status).toBe('voting') + + const tallied = ok(store.tally(session)) + if (tallied.method !== 'election') throw new Error('bad method') + if (tallied.result.outcome !== 'tie') throw new Error('expected tie') + expect(tallied.result.blanks).toBe(1) + expect(tallied.result.participants).toBe(3) + }) +}) + +// ───────────────────────────────────────────────────────────── +// L'épreuve du réel — revise chains a child +// ───────────────────────────────────────────────────────────── + +describe('review', () => { + it("'revise' chains a pre-filled child under the original protocol", async () => { + const { col, store } = await setup() + const decision = ok(store.capture('Vendre la vieille imprimante')) + const verdict = ok( + store.runTriage({ + title: decision.title, tags: decision.tags, scope: decision.scope, + reversibility: 'irreversible', weight: 'light', urgent: false, + }), + ) as Verdict + expect(verdict.reviewRequired).toBe(true) + + ok(store.applyPath(decision, verdict)) + expect(decision.status).toBe('adopted') + expect(decision.review?.dueAt).toBeDefined() + + const child = ok(store.reviewVerdict(decision.id, 'revise', 'Elle sert encore.')) as Decision + expect(decision.review?.verdict).toBe('revise') + expect(child.parentDecisionId).toBe(decision.id) + expect(child.chainKind).toBe('revision') + expect(child.status).toBe('draft') + expect(child.title).toBe(`Réviser — ${decision.title}`) + expect(col.decisions.some(d => d.id === child.id)).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Templates — the seven founding bundles +// ───────────────────────────────────────────────────────────── + +describe('templates', () => { + it('exposes the seven cards, blank first, standards re-labelled', () => { + expect(TEMPLATE_CARDS).toHaveLength(7) + expect(TEMPLATE_CARDS[0]!.id).toBe('blank') + expect(TEMPLATE_CARDS[0]!.subtitle).toContain('observatoire') + for (const id of ['informal', 'association', 'cooperative', 'commune', 'free-currency']) { + const card = TEMPLATE_CARDS.find(c => c.id === id)! + expect(card.subtitle).toBe('point de départ — tout est amendable par décision') + } + expect(TEMPLATE_CARDS.find(c => c.id === 'symmetric')!.title).toBe('Institution symétrique') + }) + + it('blank passes validateBundle and satisfies the consent invariant', () => { + const bundle = buildTemplateBundle('blank', buildOpts('page-blanche')) + const { bundle: validated, issues } = validateBundle(JSON.parse(JSON.stringify(bundle))) + expect(validated).toBeDefined() + expect(issues.filter(i => i.level === 'error')).toHaveLength(0) + // Consent ONLY — the invariant satisfied, everything else falls back. + expect(bundle.protocols).toHaveLength(1) + expect(bundle.protocols[0]!.method).toBe('consent') + const settings = resolveSettings(bundle.clauses, bundle.versions) + expect(hasConsentProtocol(settings)).toBe(true) + expect(settings.triage.requireEffects).toBe('none') + expect(settings.protocolByRange.large).toBeUndefined() + }) + + it('builds the seven templates with every clause founded by an adopted decision', () => { + const cards: TemplateId[] = TEMPLATE_CARDS.map(c => c.id) + for (const id of cards) { + const bundle = buildTemplateBundle(id, buildOpts(`t-${id}`)) + const { issues } = validateBundle(JSON.parse(JSON.stringify(bundle))) + expect(issues.filter(i => i.level === 'error')).toHaveLength(0) + + // Every clause: a 'current' version born of an ADOPTED founding decision. + for (const clause of bundle.clauses) { + expect(clause.currentVersionId).toBeDefined() + const version = bundle.versions.find(v => v.id === clause.currentVersionId)! + expect(version.status).toBe('current') + const foundingDecision = bundle.decisions.find(d => d.id === version.decisionId)! + expect(foundingDecision.status).toBe('adopted') + } + + // The first proposed decision, in the infinitive. + expect(bundle.decisions.some( + d => d.title === 'Adopter notre Pacte' && d.status === 'draft' && d.route === 'collective', + )).toBe(true) + + const settings = resolveSettings(bundle.clauses, bundle.versions) + expect(hasConsentProtocol(settings)).toBe(true) + } + }) + + it('wires protocolByRange.large: nuanced for standards, inertial binary for free-currency', () => { + const assoc = buildTemplateBundle('association', buildOpts('assoc')) + const assocSettings = resolveSettings(assoc.clauses, assoc.versions) + const assocLarge = assoc.protocols.find(p => p.id === assocSettings.protocolByRange.large)! + expect(assocLarge.method).toBe('nuanced') + expect(assocSettings.triage.requireEffects).toBe('binding') + + const g1 = buildTemplateBundle('free-currency', buildOpts('g1')) + const g1Settings = resolveSettings(g1.clauses, g1.versions) + const g1Large = g1.protocols.find(p => p.id === g1Settings.protocolByRange.large)! + expect(g1Large.method).toBe('binary') + expect(g1Large.modeParams).toBe('D30M50B.1G.2') + expect(g1Large.description).toContain('héritage Toile de Confiance') + + const informal = buildTemplateBundle('informal', buildOpts('informel')) + expect(resolveSettings(informal.clauses, informal.versions).triage.requireEffects).toBe('none') + }) + + it('symmetric seeds the declaration and the founding draft decision', () => { + const bundle = buildTemplateBundle('symmetric', buildOpts('sym')) + expect(bundle.collective.isTransparent).toBe(true) + expect(bundle.docs.some(d => d.role === 'reference' && d.title === 'Déclaration de symétrie')).toBe(true) + expect(bundle.decisions.some( + d => d.title === 'Déclarer notre symétrie' && d.status === 'draft', + )).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Collective store — import path & refusals +// ───────────────────────────────────────────────────────────── + +describe('collective store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('refuses an import colliding with an existing collective id', async () => { + const col = useCollectiveStore() + const bundle = buildTemplateBundle('blank', buildOpts('collision')) + const first = await col.importJson(JSON.stringify(bundle), true) + expect(first.state).toBeDefined() + const second = await col.importJson(JSON.stringify(bundle), true) + expect(second.collided).toBe(true) + expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true) + }) + + it('resolves settings and the me profile from the active state', async () => { + const col = useCollectiveStore() + await col.createFromTemplate('commune', { + name: 'La Commune', slug: `commune-${crypto.randomUUID().slice(0, 8)}`, + color: '#16a34a', icon: 'i-lucide-landmark', + meName: 'Yvv', memberNames: MEMBERS, + }) + expect(col.me?.displayName).toBe('Yvv') + expect(col.hasConsent).toBe(true) + expect(col.settings?.triage.requireEffects).toBe('binding') + expect(col.pactDoc?.description).toBe('Notre contrat social — sacralisé, jamais immuable') + expect(col.exportJson()).toContain('"schemaVersion": 2') + }) + + it('actions refuse politely without an active collective', () => { + const store = useDecisionsStore() + const result = store.capture('Sans collectif') + expect(isRefusal(result)).toBe(true) + }) + + it('loads the Atelier du Canal seed through the same import path', async () => { + const col = useCollectiveStore() + const result = await col.loadSeed('atelier-du-canal') + // Idempotence guard: an earlier test may have already imported it — a + // collision is then the expected polite refusal, never a crash. + if (result.state) { + expect(col.current?.collective.slug).toBe('atelier-du-canal') + expect(col.hasConsent).toBe(true) + } else { + expect(result.collided).toBe(true) + } + }) +})