forked from yvv/decision
- overlays Nuxt UI (UModal/USlideover) : géométrie posée en CSS (assets/css/overlays.css) — les utilitaires tailwind attendus par @nuxt/ui ne sont pas générés (UnoCSS ne scanne pas node_modules) : drawer mobile et modales de confirmation se rendaient hors écran - a11y : title/description sur les 3 overlays (DialogTitle/Description rendus, warnings reka-ui éteints), fermeture du drawer ancrée et testée - suppression du collectif actif : bascule sur un collectif restant — jamais d'accueil orphelin (test store ajouté, 343 vitest verts) - observatoire : la route « transmis » entre dans les barres (base 100 %) - overflows mobiles à zéro : formules KaTeX (défilement), table vigueur (défilement < 768px), pill cible (retour à la ligne), inputs decider et mandat (border-box) - vérifié navigateur : drawer, modale, 14 routes sans débordement, zéro erreur console Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
386 lines
17 KiB
Vue
386 lines
17 KiB
Vue
<script setup lang="ts">
|
|
// <!-- ld-v2 --> /decisions/observatoire — L'Observatoire (Δ12) : comment nous
|
|
// décidons, dans les faits. Tout est dérivé des stores en computed purs ; des
|
|
// faits comptés, des barres sobres aux couleurs du mood — jamais un score.
|
|
import type { DecisionRoute, Id } from '~/types/domain'
|
|
import { useCollectiveStore } from '~/stores/collective'
|
|
import { useDecisionsStore } from '~/stores/decisions'
|
|
import {
|
|
MATURATION_CARD, OBSERVATORY_SUBTITLE, OBSERVATORY_TITLE,
|
|
REVIEW_TITLE, REVIEW_VERDICTS, REVOKED_SECTION, ROUTE_SHORT,
|
|
} from '~/lexicon'
|
|
import { formatDay } from '~/components/mandates/mandateUi'
|
|
|
|
const col = useCollectiveStore()
|
|
const decisionsStore = useDecisionsStore()
|
|
const reviewLabels = REVIEW_VERDICTS
|
|
|
|
// ── Filtres : personne / cercle / tag / période ──
|
|
const fPerson = ref(''); const fCircle = ref(''); const fTag = ref(''); const fPeriod = ref('')
|
|
const tagOptions = computed(() => [...new Set(col.decisions.flatMap(d => d.tags))].sort())
|
|
const since = computed(() =>
|
|
fPeriod.value === '' ? '' : new Date(Date.now() - Number(fPeriod.value) * 86_400_000).toISOString(),
|
|
)
|
|
const filtered = computed(() => col.decisions.filter(d =>
|
|
(fPerson.value === '' || d.authorId === fPerson.value)
|
|
&& (fCircle.value === '' || d.scope.circleIds.includes(fCircle.value))
|
|
&& (fTag.value === '' || d.tags.includes(fTag.value))
|
|
&& (since.value === '' || (d.decidedAt ?? d.createdAt) >= since.value),
|
|
))
|
|
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
|
|
|
|
// ── Autonomie : ratio par route, maturité, temps médians, participation ──
|
|
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
|
|
const routeBars = computed(() => {
|
|
const total = Math.max(1, engaged.value.length)
|
|
return ROUTES.map((r) => {
|
|
const n = engaged.value.filter(d => d.route === r).length
|
|
return { label: ROUTE_SHORT[r], value: n, display: `${n} · ${Math.round((n / total) * 100)} %` }
|
|
})
|
|
})
|
|
const recordedCount = computed(() => engaged.value.filter(d => d.route === 'record').length)
|
|
const tooledCount = computed(() => engaged.value.length - recordedCount.value)
|
|
|
|
function median(xs: number[]): number {
|
|
if (xs.length === 0) return 0
|
|
const s = [...xs].sort((a, b) => a - b)
|
|
const mid = Math.floor(s.length / 2)
|
|
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
|
|
}
|
|
const medianBars = computed(() => ROUTES.map((r) => {
|
|
const days = filtered.value
|
|
.filter(d => d.route === r && d.decidedAt !== undefined)
|
|
.map(d => (new Date(d.decidedAt!).getTime() - new Date(d.createdAt).getTime()) / 86_400_000)
|
|
const m = median(days)
|
|
return { label: ROUTE_SHORT[r], value: m, display: m === 0 ? '—' : m < 1 ? `${Math.max(1, Math.round(m * 24))} h` : `${Math.round(m)} j` }
|
|
}))
|
|
|
|
const participation = computed(() => {
|
|
const ids = new Set(filtered.value.map(d => d.id))
|
|
const closed = col.sessions.filter(s => ids.has(s.decisionId) && s.status !== 'open' && s.corpusSize > 0)
|
|
if (closed.length === 0) return { value: '—', hint: 'aucune session close' }
|
|
const avg = closed.reduce((sum, s) => sum + decisionsStore.activeVotes(s.id).length / s.corpusSize, 0) / closed.length
|
|
return { value: `${Math.round(avg * 100)} %`, hint: `sur ${closed.length} session${closed.length > 1 ? 's' : ''} close${closed.length > 1 ? 's' : ''}` }
|
|
})
|
|
|
|
const heavier = computed(() => engaged.value.filter(d => d.routeOverridden && d.overrideNote === undefined).length)
|
|
const lighter = computed(() => engaged.value.filter(d => d.overrideNote !== undefined).length)
|
|
const scopeKept = computed(() => engaged.value.filter(d => d.scopeKeptNote !== undefined).length)
|
|
|
|
// ── Ressources PAR UNITÉ — l'huile et l'eau, jamais additionnées ──
|
|
const resourceLines = computed(() => {
|
|
const byUnit = new Map<string, { total: number; count: number }>()
|
|
for (const d of engaged.value) {
|
|
if (!d.resources?.amount) continue
|
|
const unit = d.resources.unit ?? 'sans unité'
|
|
const line = byUnit.get(unit) ?? { total: 0, count: 0 }
|
|
line.total += d.resources.amount
|
|
line.count += 1
|
|
byUnit.set(unit, line)
|
|
}
|
|
return [...byUnit.entries()].map(([unit, l]) => ({
|
|
unit, total: l.total.toLocaleString('fr-FR'), count: l.count,
|
|
}))
|
|
})
|
|
|
|
// ── Épreuves du réel ──
|
|
const now = new Date().toISOString()
|
|
const reviewsDue = computed(() => filtered.value
|
|
.filter(d => d.review !== undefined && d.review.verdict === undefined && d.review.dueAt <= now)
|
|
.map(d => ({ id: d.id, title: d.title, dueAt: d.review!.dueAt })))
|
|
const reviewCount = (v: 'confirmed' | 'revise' | 'revoke') =>
|
|
filtered.value.filter(d => d.review?.verdict === v).length
|
|
|
|
// ── Révoquées, consignations, maturation ──
|
|
const revoked = computed(() => filtered.value
|
|
.filter(d => d.status === 'revoked')
|
|
.map(d => ({
|
|
decision: d,
|
|
revocation: col.decisions.find(c => c.parentDecisionId === d.id && c.chainKind === 'revocation'),
|
|
})))
|
|
const records = computed(() => filtered.value
|
|
.filter(d => d.route === 'record')
|
|
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)))
|
|
|
|
const maturations = computed(() => {
|
|
const pairs = new Map<string, { tags: [string, string]; ids: Set<Id> }>()
|
|
for (const r of records.value) {
|
|
const tags = [...new Set(r.tags)].sort()
|
|
for (let i = 0; i < tags.length; i++) {
|
|
for (let j = i + 1; j < tags.length; j++) {
|
|
const key = `${tags[i]}|${tags[j]}`
|
|
const entry = pairs.get(key) ?? { tags: [tags[i]!, tags[j]!], ids: new Set<Id>() }
|
|
entry.ids.add(r.id)
|
|
pairs.set(key, entry)
|
|
}
|
|
}
|
|
}
|
|
const ripe = [...pairs.values()].filter(p => p.ids.size >= 3)
|
|
.sort((a, b) => b.ids.size - a.ids.size)
|
|
const seen = new Set<string>()
|
|
return ripe.filter((p) => {
|
|
const sig = [...p.ids].sort().join(',')
|
|
if (seen.has(sig)) return false
|
|
seen.add(sig)
|
|
return true
|
|
}).slice(0, 4)
|
|
})
|
|
|
|
// ── Élagage & décisions jamais revues ──
|
|
const sixMonthsAgo = new Date(Date.now() - 180 * 86_400_000).toISOString()
|
|
const unusedProtocols = computed(() => col.protocols.filter(p =>
|
|
!col.decisions.some(d => d.protocolId === p.id) && !col.sessions.some(s => s.protocolId === p.id)))
|
|
const idleMandates = computed(() => col.mandates.filter(m =>
|
|
m.status === 'active' && m.startsAt <= sixMonthsAgo
|
|
&& !col.decisions.some(d => d.underMandateId === m.id && d.createdAt >= sixMonthsAgo)))
|
|
const reviewDelay = computed(() => col.settings?.triage.reviewDelayDays ?? 90)
|
|
const neverReviewed = computed(() => {
|
|
const limit = new Date(Date.now() - reviewDelay.value * 86_400_000).toISOString()
|
|
return filtered.value.filter(d =>
|
|
d.status === 'adopted' && d.route !== 'record' && d.review === undefined
|
|
&& d.decidedAt !== undefined && d.decidedAt <= limit)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ld-v2 -->
|
|
<div class="obs">
|
|
<header class="obs__header">
|
|
<h1 class="obs__title">{{ OBSERVATORY_TITLE }}</h1>
|
|
<p class="obs__sub">{{ OBSERVATORY_SUBTITLE }}</p>
|
|
</header>
|
|
|
|
<!-- Filtres -->
|
|
<div class="obs__filters">
|
|
<select v-model="fPerson" class="obs__select" aria-label="Filtrer par personne">
|
|
<option value="">Toutes les personnes</option>
|
|
<option v-for="p in col.people" :key="p.id" :value="p.id">{{ p.displayName }}</option>
|
|
</select>
|
|
<select v-model="fCircle" class="obs__select" aria-label="Filtrer par cercle">
|
|
<option value="">Tous les cercles</option>
|
|
<option v-for="c in col.circles" :key="c.id" :value="c.id">{{ c.name }}</option>
|
|
</select>
|
|
<select v-model="fTag" class="obs__select" aria-label="Filtrer par tag">
|
|
<option value="">Tous les tags</option>
|
|
<option v-for="t in tagOptions" :key="t" :value="t">#{{ t }}</option>
|
|
</select>
|
|
<select v-model="fPeriod" class="obs__select" aria-label="Filtrer par période">
|
|
<option value="">Depuis toujours</option>
|
|
<option value="30">30 derniers jours</option>
|
|
<option value="90">90 derniers jours</option>
|
|
<option value="365">Cette année</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Autonomie -->
|
|
<ObservatorySection title="Stats d'autonomie" icon="i-lucide-compass" sub="qui décide comment — le miroir, pas le juge">
|
|
<ObservatoryBars :items="routeBars" />
|
|
<div class="obs__tiles">
|
|
<ObservatoryStat
|
|
label="Maturité"
|
|
:value="`${recordedCount} / ${tooledCount}`"
|
|
hint="consignées / outillées — le fil de l'eau se protocolise quand il est mûr"
|
|
/>
|
|
<ObservatoryStat label="Participation moyenne" :value="participation.value" :hint="participation.hint" />
|
|
</div>
|
|
<h3 class="obs__h3">Temps médians par route</h3>
|
|
<ObservatoryBars :items="medianBars" />
|
|
<h3 class="obs__h3">Dérogations au chemin</h3>
|
|
<div class="obs__tiles">
|
|
<ObservatoryStat label="Alourdies" :value="String(heavier)" hint="un chemin plus exigeant que suggéré" />
|
|
<ObservatoryStat label="Allégées" :value="String(lighter)" hint="toujours motivées publiquement" />
|
|
<ObservatoryStat label="Périmètres maintenus" :value="String(scopeKept)" hint="affluence atteinte, maintien motivé" />
|
|
</div>
|
|
</ObservatorySection>
|
|
|
|
<!-- Ressources par unité -->
|
|
<ObservatorySection title="Ce que ça engage" icon="i-lucide-droplets" sub="par unité, lignes séparées — l'huile et l'eau ne s'additionnent pas">
|
|
<ul v-if="resourceLines.length" class="obs__list">
|
|
<li v-for="line in resourceLines" :key="line.unit" class="obs__resource">
|
|
<span class="obs__resource-total">{{ line.total }} {{ line.unit }}</span>
|
|
<span class="obs__muted">sur {{ line.count }} décision{{ line.count > 1 ? 's' : '' }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="obs__muted">Aucune ressource engagée sur la période.</p>
|
|
</ObservatorySection>
|
|
|
|
<!-- Épreuves du réel -->
|
|
<ObservatorySection :title="REVIEW_TITLE" icon="i-lucide-telescope" sub="le réel a-t-il suivi ?">
|
|
<div class="obs__tiles">
|
|
<ObservatoryStat label="Dues" :value="String(reviewsDue.length)" />
|
|
<ObservatoryStat :label="reviewLabels.confirmed" :value="String(reviewCount('confirmed'))" />
|
|
<ObservatoryStat :label="reviewLabels.revise" :value="String(reviewCount('revise'))" />
|
|
<ObservatoryStat :label="reviewLabels.revoke" :value="String(reviewCount('revoke'))" />
|
|
</div>
|
|
<ul v-if="reviewsDue.length" class="obs__list">
|
|
<li v-for="r in reviewsDue" :key="r.id">
|
|
<NuxtLink :to="`/decisions/${r.id}`" class="obs__row">
|
|
<span class="obs__row-title">{{ r.title }}</span>
|
|
<span class="obs__due">due le {{ formatDay(r.dueAt) }}</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</ObservatorySection>
|
|
|
|
<!-- Révoquées — ce qu'on en a appris -->
|
|
<ObservatorySection :title="REVOKED_SECTION" icon="i-lucide-flask-conical" sub="le droit à l'erreur, regardé avec la curiosité du chercheur">
|
|
<ul v-if="revoked.length" class="obs__list">
|
|
<li v-for="entry in revoked" :key="entry.decision.id" class="obs__revoked">
|
|
<NuxtLink :to="`/decisions/${entry.decision.id}`" class="obs__row">
|
|
<span class="obs__row-title">{{ entry.decision.title }}</span>
|
|
<span class="obs__muted">{{ formatDay(entry.decision.decidedAt ?? entry.decision.createdAt) }}</span>
|
|
</NuxtLink>
|
|
<p v-if="entry.decision.review?.note" class="obs__learned">{{ entry.decision.review.note }}</p>
|
|
<NuxtLink v-if="entry.revocation" :to="`/decisions/${entry.revocation.id}`" class="obs__chain">
|
|
<UIcon name="i-lucide-link" />
|
|
la décision qui l'a révoquée
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="obs__muted">Rien à apprendre ici pour l'instant — aucune révoquée sur la période.</p>
|
|
</ObservatorySection>
|
|
|
|
<!-- Consignations -->
|
|
<ObservatorySection title="Consignations" icon="i-lucide-notebook-pen" sub="déjà tranché ailleurs — consigné ici, tel quel">
|
|
<ul v-if="records.length" class="obs__list">
|
|
<li v-for="d in records" :key="d.id">
|
|
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row obs__row--col">
|
|
<span class="obs__row-title">{{ d.title }}</span>
|
|
<span v-if="d.decidedHow" class="obs__how">{{ d.decidedHow }}</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="obs__muted">Aucune consignation sur la période.</p>
|
|
|
|
<div v-for="m in maturations" :key="m.tags.join('|')" class="obs__card obs__card--ripe">
|
|
<p class="obs__card-title">
|
|
<UIcon name="i-lucide-sprout" />
|
|
{{ MATURATION_CARD }}
|
|
</p>
|
|
<p class="obs__muted">
|
|
{{ m.ids.size }} consignations partagent {{ m.tags.map(t => `#${t}`).join(' et ') }}.
|
|
</p>
|
|
<NuxtLink :to="`/decider?clause-nouvelle&tags=${m.tags.join(',')}`" class="ld-btn ld-btn--ghost obs__card-btn">
|
|
Protocoliser
|
|
</NuxtLink>
|
|
</div>
|
|
</ObservatorySection>
|
|
|
|
<!-- Élagage & relances -->
|
|
<ObservatorySection title="Élagage" icon="i-lucide-scissors" sub="ce qui ne sert plus mérite une revue — pas un enterrement silencieux">
|
|
<div v-for="p in unusedProtocols" :key="p.id" class="obs__card">
|
|
<p class="obs__card-title">Le protocole « {{ p.name }} » n'a jamais servi.</p>
|
|
<p class="obs__muted">Le relire, l'amender — ou l'élaguer par une décision.</p>
|
|
</div>
|
|
<div v-for="m in idleMandates" :key="m.id" class="obs__card">
|
|
<p class="obs__card-title">Le mandat « {{ m.title }} » est sans trace depuis 6 mois.</p>
|
|
<NuxtLink :to="`/mandats/${m.id}`" class="obs__chain">
|
|
<UIcon name="i-lucide-key-round" />
|
|
voir la fiche — une revue s'impose peut-être
|
|
</NuxtLink>
|
|
</div>
|
|
<template v-if="neverReviewed.length">
|
|
<h3 class="obs__h3">Jamais revues depuis {{ reviewDelay }} jours</h3>
|
|
<ul class="obs__list">
|
|
<li v-for="d in neverReviewed" :key="d.id">
|
|
<NuxtLink :to="`/decisions/${d.id}`" class="obs__row">
|
|
<span class="obs__row-title">{{ d.title }}</span>
|
|
<span class="obs__muted">décidée le {{ formatDay(d.decidedAt) }}</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
<p v-if="!unusedProtocols.length && !idleMandates.length && !neverReviewed.length" class="obs__muted">
|
|
Rien à élaguer — tout ce qui existe sert encore.
|
|
</p>
|
|
</ObservatorySection>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.obs { max-width: 52rem; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.25rem; }
|
|
.obs__title { margin: 0; font-size: clamp(1.375rem, 3.5vw, 1.75rem); font-weight: 800; letter-spacing: -0.01em; }
|
|
.obs__sub { margin: 0.25rem 0 0; font-size: 0.9375rem; font-style: italic; color: var(--mood-text-muted); }
|
|
.obs__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
.obs__select {
|
|
flex: 1 1 10rem;
|
|
padding: 0.5rem 0.75rem;
|
|
font: inherit;
|
|
font-size: 0.875rem;
|
|
color: var(--mood-text);
|
|
background: var(--mood-input-bg);
|
|
border: none;
|
|
border-radius: var(--r-input);
|
|
box-shadow: inset 0 0 0 1px var(--mood-input-border);
|
|
}
|
|
.obs__select:focus { outline: none; box-shadow: inset 0 0 0 2px var(--mood-input-focus); }
|
|
.obs__h3 {
|
|
margin: 0.375rem 0 0;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.obs__tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.625rem; }
|
|
.obs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
|
.obs__row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: var(--r-input);
|
|
background: var(--mood-accent-soft);
|
|
text-decoration: none;
|
|
color: var(--mood-text);
|
|
transition: transform 0.1s ease;
|
|
flex-wrap: wrap;
|
|
}
|
|
.obs__row:hover { transform: translateY(-1px); }
|
|
.obs__row--col { flex-direction: column; align-items: flex-start; gap: 0.125rem; }
|
|
.obs__row-title { font-size: 0.875rem; font-weight: 600; min-width: 0; }
|
|
.obs__due { font-size: 0.75rem; font-weight: 700; color: var(--mood-status-fenetre); white-space: nowrap; }
|
|
.obs__muted { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
|
|
.obs__how { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
|
|
.obs__resource { display: flex; align-items: baseline; gap: 0.625rem; flex-wrap: wrap; }
|
|
.obs__resource-total { font-size: 1.125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.obs__revoked { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
.obs__learned {
|
|
margin: 0;
|
|
padding-left: 0.75rem;
|
|
font-size: 0.8125rem;
|
|
font-style: italic;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.obs__chain {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--mood-accent);
|
|
text-decoration: none;
|
|
padding-left: 0.75rem;
|
|
}
|
|
.obs__chain:hover { text-decoration: underline; }
|
|
.obs__card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
padding: 0.875rem 1rem;
|
|
border-radius: var(--r-icon);
|
|
background: var(--mood-accent-soft);
|
|
}
|
|
.obs__card--ripe { background: var(--mood-status-vigueur-bg); }
|
|
.obs__card-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin: 0;
|
|
font-size: 0.9375rem;
|
|
font-weight: 700;
|
|
}
|
|
.obs__card-btn { align-self: flex-start; margin-top: 0.375rem; }
|
|
</style>
|