v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Decision[]>
|
||||
adviceRequests: ComputedRef<Decision[]>
|
||||
openVotes: ComputedRef<SessionFeedItem[]>
|
||||
toCrystallize: ComputedRef<SessionFeedItem[]>
|
||||
dossiersComplete: ComputedRef<Decision[]>
|
||||
tiesToBreak: ComputedRef<SessionFeedItem[]>
|
||||
reviewsDue: ComputedRef<Decision[]>
|
||||
mandateReportsDue: ComputedRef<MandateReportDueItem[]>
|
||||
boundaryObjections: ComputedRef<Objection[]>
|
||||
overflowingScopes: ComputedRef<Decision[]>
|
||||
prioritiesAsked: ComputedRef<Concern[]>
|
||||
suggestions: ComputedRef<FeedSuggestion[]>
|
||||
collectiveActivity: ComputedRef<Decision[]>
|
||||
myCount: ComputedRef<number>
|
||||
}
|
||||
|
||||
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<Id | null>(() => col.me?.id ?? null)
|
||||
|
||||
/** Ids of the decisions where a live Concern names me. */
|
||||
const concernedDecisionIds = computed<Set<Id>>(() => {
|
||||
const me = myId.value
|
||||
const set = new Set<Id>()
|
||||
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<SessionFeedItem[]>(() => {
|
||||
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<SessionFeedItem[]>(() =>
|
||||
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<SessionFeedItem[]>(() =>
|
||||
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<MandateReportDueItem[]>(() => {
|
||||
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<FeedSuggestion[]>(() => {
|
||||
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<string, number> => {
|
||||
const map = new Map<string, number>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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-*).
|
||||
// Enveloppé sous le nom `useLibreMood` pour éviter la collision avec le composable du layer.
|
||||
|
||||
export interface Mood {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const moods: Mood[] = [
|
||||
{ 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_v2', defaultId: 'source' })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user