- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
/**
|
|
* 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<InertiaPreset, { majorityPct: number; gradientExponent: number }> = {
|
|
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<InertiaPreset, string> = {
|
|
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<string, { label: string; icon: string }> = {
|
|
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<string, string> = {
|
|
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<Id, ClauseSessionGauge>
|
|
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,
|
|
}
|
|
}
|