/** * 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 adviceRequests: ComputedRef openVotes: ComputedRef toCrystallize: ComputedRef dossiersComplete: ComputedRef tiesToBreak: ComputedRef reviewsDue: ComputedRef mandateReportsDue: ComputedRef boundaryObjections: ComputedRef overflowingScopes: ComputedRef prioritiesAsked: ComputedRef suggestions: ComputedRef collectiveActivity: ComputedRef myCount: ComputedRef } 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(() => col.me?.id ?? null) /** Ids of the decisions where a live Concern names me. */ const concernedDecisionIds = computed>(() => { const me = myId.value const set = new Set() 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(() => { 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(() => 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(() => 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(() => { 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(() => { 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 => { const map = new Map() 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, } }