Files
decision/frontend/app/composables/useFeed.ts
T
YvvandClaude Fable 5 d886302b59 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>
2026-08-11 10:30:35 +02:00

308 lines
11 KiB
TypeScript

/**
* 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,
}
}