- engine/ : parametric (médiane basse, cristallisation, impact linear-share, bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome), settings (resolveSettings + replis), triage (R-U→R6, phrases françaises), impact (concernés calculés), électionResult (blanc, quorum, égalité sans départage machine) — 296 tests vitest verts - moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits), tokens routes/états, socle borderless, print A4, tampon 井 - data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée - Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test - backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer) - test anti-lexique (marqueur ld-v2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
/**
|
||
* Perimeter engine — who is concerned by THIS decision.
|
||
*
|
||
* Pure functions — no store, no I/O. The store assembles the inputs and
|
||
* persists the result as Concern rows (origin 'computed').
|
||
*
|
||
* The computed union (BLUEPRINT-V2.md « Périmètres et inclusion ») :
|
||
* members of the scoped circles ∪ persons named by the author
|
||
* ∪ holders of active mandates whose domain intersects the scoped circles.
|
||
* Every inclusion carries its human-readable French reason — auditable in
|
||
* one tap, never « la machine t'a désigné ».
|
||
*
|
||
* MEMBERSHIP CHOICE (v2, documented): circle membership is NOMINATIVE per
|
||
* circle (Circle.memberIds is the ONLY membership rule). Circle nesting
|
||
* (parentCircleId, « couches d'oignon ») does NOT propagate membership:
|
||
* a parent circle does NOT automatically include the members of its child
|
||
* circles, nor the reverse. Nesting serves ONE gesture — widening the
|
||
* perimeter by one notch (« élargir d'un cran ») — which explicitly adds
|
||
* the parent circle to the scope; the computation never infers it.
|
||
*/
|
||
|
||
import type { Circle, Decision, Id, Mandate } from '~/types/domain'
|
||
|
||
export interface ConcernedEntry {
|
||
personId: Id
|
||
/** French reason shown on tap (becomes Concern.reason). */
|
||
reason: string
|
||
}
|
||
|
||
/**
|
||
* Flatten circle ids into the set of their DIRECT nominative members.
|
||
*
|
||
* NON-PROPAGATION (see module header): only Circle.memberIds of the listed
|
||
* circles count. Members of child (or parent) circles are NOT included —
|
||
* widening is a scope gesture, never an automatic inference.
|
||
* Unknown circle ids are ignored (robustness over crash).
|
||
*/
|
||
export function expandCircleMembers(circleIds: Id[], circles: Circle[]): Set<Id> {
|
||
const members = new Set<Id>()
|
||
for (const circleId of circleIds) {
|
||
const circle = circles.find(c => c.id === circleId)
|
||
if (!circle) continue
|
||
for (const personId of circle.memberIds) members.add(personId)
|
||
}
|
||
return members
|
||
}
|
||
|
||
/**
|
||
* Compute the concerned persons of a decision scope, each with its reason.
|
||
*
|
||
* Deduplication: first reason wins, with source priority
|
||
* circle membership > named by author > mandate holder.
|
||
* The author is EXCLUDED from the list (they decide, they are not
|
||
* « concerned » by their own perimeter).
|
||
*
|
||
* Mandates count only when status 'active' and when their domain.circleIds
|
||
* INTERSECTS the scoped circles (any shared circle is enough — a mandate
|
||
* holder is concerned as soon as the decision touches their domain).
|
||
*/
|
||
export function computeConcerned(
|
||
scope: Decision['scope'],
|
||
circles: Circle[],
|
||
mandates: Mandate[],
|
||
authorId: Id,
|
||
): ConcernedEntry[] {
|
||
// Map preserves insertion order; first reason wins (priority by pass order).
|
||
const concerned = new Map<Id, string>()
|
||
|
||
// 1. Members of the scoped circles (highest priority reason).
|
||
for (const circleId of scope.circleIds) {
|
||
const circle = circles.find(c => c.id === circleId)
|
||
if (!circle) continue
|
||
for (const personId of circle.memberIds) {
|
||
if (personId === authorId) continue
|
||
if (!concerned.has(personId)) {
|
||
concerned.set(personId, `membre du cercle ${circle.name}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Persons named by the author.
|
||
for (const personId of scope.personIds) {
|
||
if (personId === authorId) continue
|
||
if (!concerned.has(personId)) {
|
||
concerned.set(personId, 'nommé·e par l\'auteur')
|
||
}
|
||
}
|
||
|
||
// 3. Holders of active mandates whose domain intersects the scoped circles.
|
||
const scopedCircleIds = new Set(scope.circleIds)
|
||
for (const mandate of mandates) {
|
||
if (mandate.status !== 'active') continue
|
||
if (!mandate.domain.circleIds.some(id => scopedCircleIds.has(id))) continue
|
||
if (mandate.holderId === authorId) continue
|
||
if (!concerned.has(mandate.holderId)) {
|
||
concerned.set(mandate.holderId, `titulaire du mandat ${mandate.title}`)
|
||
}
|
||
}
|
||
|
||
return [...concerned].map(([personId, reason]) => ({ personId, reason }))
|
||
}
|