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,280 @@
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
},
|
||||
})
|
||||
+1096
-210
File diff suppressed because it is too large
Load Diff
@@ -265,7 +265,7 @@ export const useDocumentsStore = defineStore('documents', {
|
||||
const map: Record<string, ItemVersion[]> = {}
|
||||
itemIds.forEach((id, i) => {
|
||||
const r = results[i]
|
||||
map[id] = r.status === 'fulfilled' ? r.value : []
|
||||
map[id] = r?.status === 'fulfilled' ? r.value : []
|
||||
})
|
||||
this.allItemVersions = map
|
||||
} finally {
|
||||
|
||||
@@ -49,7 +49,7 @@ export const useOrganizationsStore = defineStore('organizations', {
|
||||
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
|
||||
if (stored && this.organizations.some(o => o.slug === stored)) {
|
||||
this.activeSlug = stored
|
||||
} else if (this.organizations.length > 0) {
|
||||
} else if (this.organizations[0]) {
|
||||
this.activeSlug = this.organizations[0].slug
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
Reference in New Issue
Block a user