/** * Shared UI helpers of the mandate screens (carte, fiche, wizard) — v2. * INVARIANT Δ7 : no gauge, no score, no aggregate on a mandate — COUNTED * FACTS only, never synthesized. The lexicon stays the single source for * everything it already names (MANDATE_EXERCISE, NOMINATION_LABELS…). */ import type { Decision, Id, Mandate, MandateReport, NominationMethod, Objection, } from '~/types/domain' /** Mandate statuses in plain French — proposé / actif / expiré / révoqué. */ export const MANDATE_STATUS_LABELS: Record = { proposed: 'proposé', active: 'actif', expired: 'expiré', revoked: 'révoqué', } /** Global status-pill classes reused for mandates (moods.css). */ export const MANDATE_STATUS_PILL: Record = { proposed: 'status-draft', active: 'status-adopted', expired: 'status-closed', revoked: 'status-revoked', } /** Objection statuses in plain French (fiche mandat — listes cliquables). */ export const OBJECTION_STATUS_LABELS: Record = { open: 'ouverte', withdrawn: 'retirée', integrated: 'intégrée', escalated: 'escaladée', } /** Counted facts of a mandate — raw numbers, never a synthesis. */ export interface MandateFacts { traced: Decision[] objections: Objection[] reportsDelivered: number reportsTotal: number nextReportDueAt?: string line: string } export function mandateFacts( mandate: Mandate, decisions: Decision[], objections: Objection[], ): MandateFacts { const traced = decisions .filter(d => d.underMandateId === mandate.id) .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) const tracedIds = new Set(traced.map(d => d.id)) const own = objections.filter(o => tracedIds.has(o.decisionId)) const delivered = mandate.reports.filter(r => r.deliveredAt !== undefined).length const total = mandate.reports.length const next = mandate.reports .filter(r => r.deliveredAt === undefined) .sort((a, b) => (a.dueAt < b.dueAt ? -1 : 1))[0] const parts = [ `${traced.length} décision${traced.length > 1 ? 's' : ''} tracée${traced.length > 1 ? 's' : ''}`, `${own.length} objection${own.length > 1 ? 's' : ''}`, ] if (total > 0) parts.push(`${delivered}/${total} rapport${total > 1 ? 's' : ''}`) const facts: MandateFacts = { traced, objections: own, reportsDelivered: delivered, reportsTotal: total, line: parts.join(' · '), } if (next) facts.nextReportDueAt = next.dueAt return facts } /** Next report cadence, inferred from the existing dueAt spacing. */ export function reportIntervalMs(mandate: Mandate): number | undefined { const dues = mandate.reports.map((r: MandateReport) => r.dueAt).sort() const last = dues[dues.length - 1] if (last === undefined) return undefined const previous = dues.length >= 2 ? dues[dues.length - 2]! : mandate.startsAt const gap = new Date(last).getTime() - new Date(previous).getTime() return gap > 0 ? gap : undefined } /** Short pedagogy of the 6 nomination modalities (wizard step 2). */ export interface NominationPedagogy { icon: string desc: string pro: string con: string rule?: string } export const ELECTION_CLOSE_RULE = 'Pluralité, blanc possible — en cas d\'égalité, vous départagez, jamais l\'outil.' export const NOMINATION_PEDAGOGY: Record = { 'ratified-self': { icon: 'i-lucide-user-check', desc: 'Tu te proposes toi-même ; le cercle ratifie par consentement.', pro: 'le plus rapide — l\'élan vient de la personne', con: 'suppose un vrai espace d\'objection', }, 'election-no-candidate': { icon: 'i-lucide-vote', desc: 'Chacun désigne la personne qu\'il juge la plus indiquée — personne ne se déclare.', pro: 'révèle la légitimité réelle, sans campagne', con: 'demande un cercle qui se connaît', rule: ELECTION_CLOSE_RULE, }, 'nuanced-vote': { icon: 'i-lucide-list-ordered', desc: 'Chaque nom proposé reçoit une nuance, de « Pas du tout » à « Tout à fait ».', pro: 'mesure l\'adhésion, pas seulement la préférence', con: 'plus long à dépouiller qu\'un consentement', }, 'consent': { icon: 'i-lucide-handshake', desc: 'La nomination passe si aucune objection argumentée ne tient.', pro: 'sobre et robuste — l\'objection nourrit la décision', con: 'le silence doit pouvoir être un vrai oui', }, 'draw': { icon: 'i-lucide-dices', desc: 'Le sort désigne parmi les volontaires du cercle électeur.', pro: 'égalité parfaite — casse les notabilités', con: 'demande d\'accompagner la personne tirée', }, 'rotation': { icon: 'i-lucide-rotate-ccw', desc: 'Le mandat tourne à échéance fixe entre les membres du cercle.', pro: 'le pouvoir circule par construction', con: 'transmission à soigner à chaque tour', }, } /** jj mois aaaa — the one date format of the mandate screens. */ export function formatDay(iso: string | undefined): string { if (!iso) return '—' return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', }) }