forked from yvv/decision
v2 : couche données complète + shell + primitives communes
- 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 <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,
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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