/** * Shared pure helpers for the texts screens — library (/textes), living * document (/textes/[slug]) and formula atelier (/textes/formules). * No I/O, no store access: pages assemble, these functions compute. */ import type { Clause, ClauseVersion, CollectiveSettings, Decision, DecisionStatus, Id, InertiaPreset, ISODate, Json, Person, Protocol, Vote, VoteSession, } from '~/types/domain' import { INERTIA_LABELS } from '~/lexicon' // ── Shared view-model shapes (SFC scripts cannot export types) ── /** A proposed clause version, content first — author folded behind it. */ export interface ProposedEntry { version: ClauseVersion authorName: string decisionId?: Id decisionTitle?: string } /** One link of a clause's decision chain (founding, then amendments). */ export interface ChainEntry { id: Id title: string status: DecisionStatus decidedAt?: string } /** Query-preset state of the formula atelier (?W=&T=&M=&B=&G=&C=&S=). */ export interface AtelierInit { W?: number T?: number M?: number B?: number G?: number C?: number S?: number } // ── Inertia presets — REAL wiring (types/domain.ts) ────────── export const INERTIA_PARAMS: Record = { low: { majorityPct: 50, gradientExponent: 0.1 }, standard: { majorityPct: 50, gradientExponent: 0.2 }, high: { majorityPct: 60, gradientExponent: 0.4 }, max: { majorityPct: 66, gradientExponent: 0.6 }, } /** Tint per preset — derived from the mood custom properties. */ export const INERTIA_COLORS: Record = { low: 'var(--mood-success)', standard: 'var(--mood-accent)', high: 'var(--mood-warning)', max: 'var(--mood-error)', } export const INERTIA_ORDER: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max'] // ── Sections — labels + icons for known tags, graceful fallback ── const SECTION_META: Record = { preambule: { label: 'Préambule', icon: 'i-lucide-compass' }, introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' }, mission: { label: 'Mission', icon: 'i-lucide-target' }, composition: { label: 'Composition', icon: 'i-lucide-users' }, engagements: { label: 'Engagements', icon: 'i-lucide-heart-handshake' }, fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' }, technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' }, qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' }, aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' }, certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' }, conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' }, annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' }, formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' }, inertie: { label: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' }, ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' }, // the raw tag never reaches the UI — this label replaces it triage: { label: 'Seuils et fenêtres', icon: 'i-lucide-route' }, protocoles: { label: 'Protocoles de vote', icon: 'i-lucide-vote' }, } export function sectionMeta(tag: string): { label: string; icon: string } { const known = SECTION_META[tag] if (known) return known const label = tag.charAt(0).toUpperCase() + tag.slice(1) return { label, icon: 'i-lucide-file-text' } } // ── Formatting ─────────────────────────────────────────────── export function formatDateFr(iso: ISODate | undefined): string { if (!iso) return '' return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) } export function formatInt(n: number): string { return n.toLocaleString('fr-FR') } // ── Live sessions on clause amendments — the mini-gauges ───── export interface ClauseSessionGauge { clauseId: Id clauseCode: string decisionId: Id decisionTitle: string positions: number // last active votes cast listSize: number // arrested voter list size closesAt: ISODate frozen: boolean } /** Last-active-vote count of a session (supersedes chains resolved). */ function activeVoteCount(sessionId: Id, votes: Vote[]): number { const sessionVotes = votes.filter(v => v.sessionId === sessionId) const superseded = new Set( sessionVotes.map(v => v.supersedesVoteId).filter((id): id is Id => id !== undefined), ) return sessionVotes.filter(v => !superseded.has(v.id)).length } /** * Real running sessions (open or frozen) over decisions that amend one of * `clauses` — one gauge per amending decision, latest session wins. */ export function liveClauseGauges( clauses: Clause[], decisions: Decision[], sessions: VoteSession[], votes: Vote[], ): ClauseSessionGauge[] { const byId = new Map(clauses.map(c => [c.id, c])) const gauges: ClauseSessionGauge[] = [] for (const decision of decisions) { if (!decision.amendsClauseId) continue const clause = byId.get(decision.amendsClauseId) if (!clause) continue const latest = sessions .filter(s => s.decisionId === decision.id) .sort((a, b) => (a.opensAt < b.opensAt ? 1 : -1))[0] if (!latest || (latest.status !== 'open' && latest.status !== 'frozen')) continue gauges.push({ clauseId: clause.id, clauseCode: clause.code, decisionId: decision.id, decisionTitle: decision.title, positions: activeVoteCount(latest.id, votes), listSize: latest.corpusSize, closesAt: latest.closesAt, frozen: latest.status === 'frozen', }) } return gauges } /** Engraved decisions linked to these clauses (井 filter of the library). */ export function engravedCount(clauses: Clause[], decisions: Decision[]): number { const ids = new Set(clauses.map(c => c.id)) return decisions.filter(d => d.engraving && d.amendsClauseId && ids.has(d.amendsClauseId)).length } // ── Versions per clause ────────────────────────────────────── /** The 'current' version of a clause, preferring clause.currentVersionId. */ export function currentVersionOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion | undefined { const mine = versions.filter(v => v.clauseId === clause.id) if (clause.currentVersionId) { const pinned = mine.find(v => v.id === clause.currentVersionId) if (pinned) return pinned } return mine.find(v => v.status === 'current') } export function proposedVersionsOf(clause: Clause, versions: ClauseVersion[]): ClauseVersion[] { return versions .filter(v => v.clauseId === clause.id && v.status === 'proposed') .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) } // ── Pact setting clauses — the VALUE IN CLEAR ──────────────── const EFFECTS_LABELS: Record = { none: 'aucune exigence', structural: 'décisions structurantes', binding: 'décisions engageantes et structurantes', } /** * One French sentence stating the current value of a Pact setting clause * (« Petit groupe : jusqu'à 5 personnes »). `protocolName` resolves the * protocols.* keys; returns null when nothing readable can be said. */ export function settingSentence( key: string, value: Json | undefined, protocolName?: string, ): string | null { if (value === undefined && !key.startsWith('protocols.')) return null switch (key) { case 'triage.smallGroupMax': return `Petit groupe : jusqu'à ${value} personnes` case 'triage.collectiveMin': return `Grand corps : à partir de ${value} personnes` case 'triage.consentMax': return `Tour de consentement : jusqu'à ${value} personnes` case 'triage.objectionWindowHours': return `Fenêtre d'objection : ${value} heures` case 'triage.adviceWindowHours': return `Fenêtre d'avis : ${value} heures` case 'triage.framingDays': return `Temps de formulation : ${value} jours` case 'triage.concernEscalateRatio': return `Affluence : traitement obligatoire à partir de ${Math.round(Number(value) * 100)} % de concernés` case 'triage.recurrenceThreshold': return `Récurrence : ${value} décisions semblables suggèrent une règle` case 'triage.reviewDelayDays': return `Épreuve du réel : ${value} jours après adoption` case 'triage.requireEffects': return `Matière exigée : ${EFFECTS_LABELS[String(value)] ?? String(value)}` } if (key.startsWith('protocols.clauseByInertia.')) { const preset = key.slice('protocols.clauseByInertia.'.length) as InertiaPreset const label = INERTIA_LABELS[preset] return protocolName && label ? `Amender une clause en ${label} : ${protocolName}` : null } if (key.startsWith('protocols.')) { return protocolName ? `Protocole retenu : ${protocolName}` : null } return null } // ── Full clause view assembly (page /textes/[slug]) ────────── export interface ClauseViewCtx { versions: ClauseVersion[] decisions: Decision[] people: Person[] protocols: Protocol[] settings: CollectiveSettings | null gauges: Map isPact: boolean memberCount: number } export interface ClauseView { clause: Clause current?: ClauseVersion proposed: ProposedEntry[] founding?: ChainEntry amendments: ChainEntry[] settingText: string | null gauge: ClauseSessionGauge | null /** « Vote WoT standard — 30 jours » — le protocole d'amendement résolu. */ amendProtocol?: string atelierLink: string | null protectedClause: boolean status: { label: string; css: string } } /** The amendment protocol a clause resolves to, via the Pact settings. */ function amendProtocolOf(preset: InertiaPreset, ctx: ClauseViewCtx): Protocol | undefined { const id = ctx.settings?.protocolByRange.clauseByInertia?.[preset] ?? ctx.settings?.protocolByRange.consent return ctx.protocols.find(p => p.id === id) } /** Everything a clause row + detail needs, computed once per clause. */ export function buildClauseView(clause: Clause, ctx: ClauseViewCtx): ClauseView { const current = currentVersionOf(clause, ctx.versions) const proposedVersions = proposedVersionsOf(clause, ctx.versions) const proposed: ProposedEntry[] = proposedVersions.map((version) => { const decision = ctx.decisions.find(d => d.id === version.decisionId) const author = ctx.people.find(p => p.id === decision?.authorId) return { version, authorName: author?.displayName ?? 'quelqu\'un du collectif', ...(decision !== undefined ? { decisionId: decision.id, decisionTitle: decision.title } : {}), } }) const chain: ChainEntry[] = ctx.decisions .filter(d => d.amendsClauseId === clause.id) .sort((a, b) => ((a.decidedAt ?? a.createdAt) < (b.decidedAt ?? b.createdAt) ? -1 : 1)) .map(d => ({ id: d.id, title: d.title, status: d.status, ...(d.decidedAt !== undefined ? { decidedAt: d.decidedAt } : {}), })) const [founding, ...amendments] = chain const protocolNameForValue = typeof current?.settingValue === 'string' ? ctx.protocols.find(p => p.id === current.settingValue)?.name : undefined const settingText = ctx.isPact && clause.settingKey ? settingSentence(clause.settingKey, current?.settingValue, protocolNameForValue) : null const protocol = amendProtocolOf(clause.inertia, ctx) const atelierLink = protocol?.method === 'binary' ? `/textes/formules?W=${ctx.memberCount}&M=${protocol.formula.majorityPct}` + `&B=${protocol.formula.baseExponent}&G=${protocol.formula.gradientExponent}` + `&C=${protocol.formula.constantBase}` : null const gauge = ctx.gauges.get(clause.id) ?? null const status = gauge ? { label: 'en vote', css: 'status-voting' } : proposed.length > 0 ? { label: 'version proposée', css: 'status-framing' } : { label: 'en vigueur', css: 'status-adopted' } return { clause, ...(current !== undefined ? { current } : {}), proposed, ...(founding !== undefined ? { founding } : {}), amendments, settingText, gauge, ...(protocol !== undefined ? { amendProtocol: `${protocol.name} — ${protocol.durationDays} jours` } : {}), atelierLink, protectedClause: clause.settingKey?.startsWith('protocols.clauseByInertia.') === true, status, } }