Files
decision/frontend/app/stores/collective.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

281 lines
8.9 KiB
TypeScript

/**
* Collective store — the tenant: which collective is active, its full local
* state (CollectiveState = Bundle minus export metadata), and the bundle
* lifecycle (create from template, import, export, seeds, delete).
*
* EXPLICIT imports only (no Nuxt auto-imports): the store must run under
* plain vitest + createPinia, and the future FastAPI adapter swaps
* data/persistence without touching any screen.
*
* Getter shortcuts (people, decisions, …) filter soft-deleted rows
* (!archivedAt) — screens never see archived entities; sync keeps them.
*/
import { defineStore } from 'pinia'
import type {
Advice,
Assent,
Circle,
Clause,
ClauseVersion,
CollectiveSettings,
CollectiveTemplate,
Concern,
Decision,
Id,
ISODate,
Mandate,
Objection,
Person,
Protocol,
TextDoc,
VoteSession,
Vote,
} from '../types/domain'
import type { CollectiveState, ImportResult } from '../data/persistence'
import {
deleteCollective,
getActiveCollectiveId,
importBundle,
listCollectiveIds,
loadState,
saveStateDebounced,
saveStateNow,
setActiveCollectiveId,
toBundle,
} from '../data/persistence'
import { hasConsentProtocol, resolveSettings } from '../engine'
import { buildTemplateBundle } from '../data/templates'
import type { TemplateId } from '../data/templates'
/** Re-exported for screens: the shape of the active collective's state. */
export type { CollectiveState } from '../data/persistence'
export interface CollectiveIndexEntry {
id: Id
slug: string
name: string
color: string
icon: string
template: CollectiveTemplate
}
export interface CreateFromTemplateOptions {
name: string
slug: string
color: string
icon: string
meName: string
memberNames: string[]
}
export type SeedName = 'duniter-g1' | 'atelier-du-canal'
interface CollectiveStoreState {
current: CollectiveState | null
activeId: Id | null
index: CollectiveIndexEntry[]
ready: boolean
}
function live<T extends { archivedAt?: ISODate }>(rows: T[] | undefined): T[] {
return (rows ?? []).filter(row => !row.archivedAt)
}
export const useCollectiveStore = defineStore('collective', {
state: (): CollectiveStoreState => ({
current: null,
activeId: null,
index: [],
ready: false,
}),
getters: {
/** The local profile — v2 single-seat. */
me(state): Person | null {
return state.current?.people.find(p => p.isMe && !p.archivedAt) ?? null
},
/** Resolved Pact settings — the Pact IS the settings store. */
settings(state): CollectiveSettings | null {
if (!state.current) return null
const pactDocId = state.current.collective.pactDocId
// Cast: Vue's UnwrapRef over the recursive Json type (settingValue)
// explodes TS instantiation depth; the runtime shape is identical.
const pactClauses = state.current.clauses.filter(
c => c.docId === pactDocId && !c.archivedAt,
) as unknown as Clause[]
const versions = state.current.versions as unknown as ClauseVersion[]
return resolveSettings(pactClauses, versions)
},
hasConsent(): boolean {
const settings = this.settings
return settings !== null && hasConsentProtocol(settings)
},
pactDoc(state): TextDoc | null {
if (!state.current) return null
return (
state.current.docs.find(
d => d.id === state.current!.collective.pactDocId && !d.archivedAt,
) ?? null
)
},
// ── Non-archived shortcuts — screens never see archived rows ──
people(state): Person[] { return live(state.current?.people) },
circles(state): Circle[] { return live(state.current?.circles) },
decisions(state): Decision[] { return live(state.current?.decisions) },
mandates(state): Mandate[] { return live(state.current?.mandates) },
docs(state): TextDoc[] { return live(state.current?.docs) },
clauses(state): Clause[] { return live(state.current?.clauses) },
versions(state): ClauseVersion[] {
// Cast: same recursive-Json unwrap issue as the settings getter.
return live(state.current?.versions as unknown as ClauseVersion[] | undefined)
},
protocols(state): Protocol[] { return live(state.current?.protocols) },
sessions(state): VoteSession[] { return live(state.current?.sessions) },
votes(state): Vote[] { return live(state.current?.votes) },
concerns(state): Concern[] { return live(state.current?.concerns) },
objections(state): Objection[] { return live(state.current?.objections) },
advices(state): Advice[] { return live(state.current?.advices) },
assents(state): Assent[] { return live(state.current?.assents) },
},
actions: {
newId(): Id {
return crypto.randomUUID()
},
now(): ISODate {
return new Date().toISOString()
},
/** LWW clock: bump updatedAt, return the entity. */
stamp<T extends { updatedAt: ISODate }>(entity: T): T {
entity.updatedAt = this.now()
return entity
},
/** Debounced save of the active collective — every mutation ends here. */
persist(): void {
if (this.current) saveStateDebounced(this.current as unknown as CollectiveState)
},
/** Load the index of local collectives + the active one. */
async init(): Promise<void> {
const ids = await listCollectiveIds()
const index: CollectiveIndexEntry[] = []
for (const id of ids) {
const state = await loadState(id)
if (!state) continue
const c = state.collective
index.push({
id: c.id, slug: c.slug, name: c.name,
color: c.color, icon: c.icon, template: c.template,
})
}
this.index = index
const activeId = await getActiveCollectiveId()
if (activeId && ids.includes(activeId)) {
await this.switchTo(activeId)
}
this.ready = true
},
async switchTo(id: Id): Promise<void> {
const state = await loadState(id)
if (!state) return
// Cast: assigning into the reactive slot re-triggers the recursive-Json
// UnwrapRef explosion (see the settings getter) — same shape at runtime.
this.current = state as unknown as typeof this.current
this.activeId = id
await setActiveCollectiveId(id)
},
/** Create a collective from one of the seven templates — SAME path as an import. */
async createFromTemplate(id: TemplateId, opts: CreateFromTemplateOptions): Promise<ImportResult> {
const bundle = buildTemplateBundle(id, {
...opts,
now: this.now(),
newId: () => this.newId(),
})
return await this.importJson(JSON.stringify(bundle), true)
},
/**
* Import a bundle (user file or seed). Collision on the collective id is
* REFUSED with a French issue — never a silent overwrite, never an id
* suffix (the id is the sync identity of the collective).
*/
async importJson(json: string, asSeed = false): Promise<ImportResult> {
const result = await importBundle(json, { asSeed })
if (!result.state) return result
if (result.collided) {
return {
issues: [
...result.issues,
{
level: 'error',
message:
'Ce collectif existe déjà sur cette machine — supprime-le d\'abord si tu veux le réimporter.',
},
],
collided: true,
}
}
await saveStateNow(result.state)
const c = result.state.collective
this.index.push({
id: c.id, slug: c.slug, name: c.name,
color: c.color, icon: c.icon, template: c.template,
})
await this.switchTo(c.id)
return result
},
/** Export the active collective as an indented schemaVersion-2 bundle. */
exportJson(): string | null {
if (!this.current) return null
return JSON.stringify(toBundle(this.current as unknown as CollectiveState, this.now()), null, 2)
},
async removeCollective(id: Id): Promise<void> {
await deleteCollective(id)
this.index = this.index.filter(entry => entry.id !== id)
if (this.activeId === id) {
this.activeId = null
this.current = null
}
},
/**
* Import a packaged seed (Duniter Ğ1 / Atelier du Canal) through the SAME
* path as a user import. The JSON files are produced by separate tooling:
* a missing file fails SOFTLY with a French issue, never a crash.
*/
async loadSeed(name: SeedName): Promise<ImportResult> {
let bundle: unknown
try {
bundle
= name === 'duniter-g1'
? (await import('../data/seeds/duniter-g1.bundle.json')).default
: (await import('../data/seeds/atelier-du-canal.bundle.json')).default
} catch {
return {
issues: [
{
level: 'error',
message: `Le jeu de démonstration « ${name} » n'est pas disponible dans cette version.`,
},
],
collided: false,
}
}
return await this.importJson(JSON.stringify(bundle), true)
},
},
})