Sprint 3 : protocoles de vote et boite a outils

Backend:
- Sessions de vote : list, close, tally, threshold details, auto-expiration
- Protocoles : update, simulate, meta-gouvernance, formulas CRUD
- Service vote enrichi : close_session, get_threshold_details, nuanced breakdown
- Schemas : ThresholdDetailOut, VoteResultOut, FormulaSimulationRequest/Result
- WebSocket broadcast sur chaque vote + fermeture session
- 25 nouveaux tests (threshold details, close, nuanced, simulation)

Frontend:
- 5 composants vote : VoteBinary, VoteNuanced, ThresholdGauge, FormulaDisplay, VoteHistory
- 3 composants protocoles : ProtocolPicker, FormulaEditor, ModeParamsDisplay
- Simulateur de formules interactif (page /protocols/formulas)
- Page detail protocole (/protocols/[id])
- Composable useWebSocket (live updates)
- Composable useVoteFormula (calcul client-side reactif)
- Integration KaTeX pour rendu LaTeX des formules

Documentation:
- API reference : 8 nouveaux endpoints documentes
- Formules : tables d'inertie, parametres detailles, simulation API
- Guide vote : vote binaire/nuance, jauge, historique, simulateur, meta-gouvernance

55 tests passes (+ 1 skipped), 126 fichiers total.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 13:29:31 +01:00
parent 2bdc731639
commit cede2a585f
25 changed files with 3964 additions and 188 deletions

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
/**
* Interactive formula parameter editor.
*
* Provides sliders and inputs for adjusting all formula parameters,
* emitting the updated config on each change.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
modelValue: FormulaConfig
}>()
const emit = defineEmits<{
'update:modelValue': [value: FormulaConfig]
}>()
/** Local reactive copy to avoid direct prop mutation. */
const local = reactive({ ...props.modelValue })
/** Sync incoming prop changes. */
watch(() => props.modelValue, (newVal) => {
Object.assign(local, newVal)
}, { deep: true })
/** Emit on any local change. */
watch(local, () => {
emit('update:modelValue', { ...local })
}, { deep: true })
/** Track optional fields. */
const showSmith = ref(local.smith_exponent !== null)
const showTechcomm = ref(local.techcomm_exponent !== null)
const showNuancedMin = ref(local.nuanced_min_participants !== null)
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
watch(showSmith, (v) => {
local.smith_exponent = v ? 0.5 : null
})
watch(showTechcomm, (v) => {
local.techcomm_exponent = v ? 0.5 : null
})
watch(showNuancedMin, (v) => {
local.nuanced_min_participants = v ? 10 : null
})
watch(showNuancedThreshold, (v) => {
local.nuanced_threshold_pct = v ? 66 : null
})
interface ParamDef {
key: string
label: string
description: string
type: 'input' | 'slider'
min: number
max: number
step: number
}
const mainParams: ParamDef[] = [
{
key: 'duration_days',
label: 'Duree (jours)',
description: 'Duree du vote en jours',
type: 'input',
min: 1,
max: 365,
step: 1,
},
{
key: 'majority_pct',
label: 'Majorite (%)',
description: 'Ratio de majorite cible a haute participation',
type: 'slider',
min: 0,
max: 100,
step: 1,
},
{
key: 'base_exponent',
label: 'Exposant de base (B)',
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
type: 'slider',
min: 0.01,
max: 1.0,
step: 0.01,
},
{
key: 'gradient_exponent',
label: 'Gradient d\'inertie (G)',
description: 'Controle la vitesse de transition vers la majorite simple',
type: 'slider',
min: 0.01,
max: 2.0,
step: 0.01,
},
{
key: 'constant_base',
label: 'Constante de base (C)',
description: 'Plancher fixe de votes requis',
type: 'input',
min: 0,
max: 100,
step: 1,
},
]
</script>
<template>
<div class="space-y-6">
<!-- Main parameters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ param.label }}
</label>
<span class="text-sm font-mono font-bold text-primary">
{{ (local as any)[param.key] }}
</span>
</div>
<template v-if="param.type === 'slider'">
<URange
v-model="(local as any)[param.key]"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<template v-else>
<UInput
v-model.number="(local as any)[param.key]"
type="number"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<p class="text-xs text-gray-500">{{ param.description }}</p>
</div>
</div>
<!-- Optional parameters -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
Parametres optionnels
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Smith exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showSmith" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere Smith (S)
</label>
</div>
<template v-if="showSmith && local.smith_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant Smith</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
</div>
<URange
v-model="local.smith_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
</template>
</div>
<!-- TechComm exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showTechcomm" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere TechComm (T)
</label>
</div>
<template v-if="showTechcomm && local.techcomm_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant TechComm</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
</div>
<URange
v-model="local.techcomm_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
</template>
</div>
<!-- Nuanced min participants -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedMin" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Participants minimum (nuance)
</label>
</div>
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
<UInput
v-model.number="local.nuanced_min_participants"
type="number"
:min="1"
:max="1000"
:step="1"
/>
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
</template>
</div>
<!-- Nuanced threshold pct -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedThreshold" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Seuil nuance (%)
</label>
</div>
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
</div>
<URange
v-model="local.nuanced_threshold_pct"
:min="50"
:max="100"
:step="1"
/>
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
</template>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
/**
* Display decoded mode params string as labeled badges/chips.
*
* Parses the compact mode params string and renders each parameter
* as a human-readable chip.
*/
const props = defineProps<{
modeParams: string
}>()
interface ParamChip {
code: string
label: string
value: string
color: string
}
const chips = computed((): ParamChip[] => {
if (!props.modeParams) return []
try {
const parsed = parseModeParams(props.modeParams)
const result: ParamChip[] = []
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
if (parsed.constant_base > 0) {
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
}
if (parsed.smith_exponent !== null) {
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
}
if (parsed.techcomm_exponent !== null) {
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
}
return result
} catch {
return []
}
})
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
<UBadge
v-for="chip in chips"
:key="chip.code"
:color="(chip.color as any)"
variant="subtle"
size="xs"
>
{{ chip.label }}: {{ chip.value }}
</UBadge>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
/**
* Dropdown/select to pick a voting protocol.
*
* Fetches protocols from the store and renders them in a USelect
* with protocol name, mode params, and vote type badge.
*/
import type { VotingProtocol } from '~/stores/protocols'
const props = defineProps<{
modelValue: string | null
voteType?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const protocols = useProtocolsStore()
onMounted(async () => {
if (protocols.protocols.length === 0) {
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
}
})
const filteredProtocols = computed(() => {
if (!props.voteType) return protocols.protocols
return protocols.protocols.filter(p => p.vote_type === props.voteType)
})
const options = computed(() => {
return filteredProtocols.value.map(p => ({
label: buildLabel(p),
value: p.id,
}))
})
function buildLabel(p: VotingProtocol): string {
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
const params = p.mode_params ? ` [${p.mode_params}]` : ''
return `${p.name} - ${typeLabel}${params}`
}
function onSelect(value: string) {
emit('update:modelValue', value || null)
}
</script>
<template>
<div>
<USelect
:model-value="modelValue ?? undefined"
:items="options"
placeholder="Selectionnez un protocole..."
:loading="protocols.loading"
value-key="value"
@update:model-value="onSelect"
/>
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
{{ protocols.error }}
</p>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
/**
* Display vote formula with KaTeX rendering.
*
* Renders the WoT threshold formula using KaTeX when available,
* falling back to a code display. Shows parameter values and
* optional Smith/TechComm criteria formulas.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
formulaConfig: FormulaConfig
showExplanation?: boolean
}>()
const showExplain = ref(props.showExplanation ?? false)
/**
* Render a LaTeX string to HTML using KaTeX, with code fallback.
*/
function renderFormula(tex: string): string {
if (typeof window !== 'undefined' && (window as any).katex) {
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
}
return `<code class="text-sm font-mono">${tex}</code>`
}
/** Main threshold formula in LaTeX. */
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
/** Smith criterion formula. */
const smithFormulaTeX = computed(() => {
if (props.formulaConfig.smith_exponent === null) return null
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
})
/** TechComm criterion formula. */
const techcommFormulaTeX = computed(() => {
if (props.formulaConfig.techcomm_exponent === null) return null
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
})
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
const parameters = computed(() => [
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
...(props.formulaConfig.smith_exponent !== null ? [{
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
}] : []),
...(props.formulaConfig.techcomm_exponent !== null ? [{
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
}] : []),
])
</script>
<template>
<div class="space-y-4">
<!-- Main formula -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
<div v-html="mainFormulaHtml" class="text-center" />
</div>
<!-- Smith criterion -->
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
<div v-html="smithFormulaHtml" class="text-center" />
</div>
<!-- TechComm criterion -->
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
<div v-html="techcommFormulaHtml" class="text-center" />
</div>
<!-- Parameters grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="param in parameters"
:key="param.code"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center gap-2 mb-1">
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
</div>
</div>
<!-- Explanation toggle -->
<div class="flex justify-end">
<UButton
variant="ghost"
color="neutral"
size="xs"
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showExplain = !showExplain"
>
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* Visual gauge showing votes vs threshold.
*
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
* a vertical threshold marker, participation statistics, and a pass/fail badge.
*/
const props = defineProps<{
votesFor: number
votesAgainst: number
threshold: number
wotSize: number
}>()
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
/** Percentage of "pour" votes relative to total votes. */
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
/** Percentage of "contre" votes relative to total votes. */
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
/** Position of the threshold marker as a percentage of total votes. */
const thresholdPosition = computed(() => {
if (totalVotes.value === 0) return 50
// Threshold as a percentage of total votes
const pct = (props.threshold / totalVotes.value) * 100
return Math.min(pct, 100)
})
/** Whether the vote passes (votes_for >= threshold). */
const isPassing = computed(() => props.votesFor >= props.threshold)
/** Participation rate. */
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
</script>
<template>
<div class="space-y-3">
<!-- Progress bar -->
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<!-- Green fill (pour) -->
<div
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
:style="{ width: `${forPct}%` }"
/>
<!-- Red fill (contre) -->
<div
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
:style="{ left: `${thresholdPosition}%` }"
>
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
Seuil
</div>
</div>
<!-- Percentage labels inside the bar -->
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
<span class="flex-1" />
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
</div>
</div>
<!-- Counts and threshold text -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<span class="text-green-600 dark:text-green-400 font-medium">
{{ votesFor }} pour
</span>
<span class="text-red-600 dark:text-red-400 font-medium">
{{ votesAgainst }} contre
</span>
</div>
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
{{ votesFor }} / {{ threshold }} requis
</div>
</div>
<!-- Participation rate -->
<div class="flex items-center justify-between text-xs text-gray-500">
<span>
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
<!-- Pass/fail badge -->
<UBadge
:color="isPassing ? 'success' : 'error'"
variant="subtle"
size="xs"
>
{{ isPassing ? 'Adopte' : 'Non adopte' }}
</UBadge>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
/**
* Binary vote component: Pour / Contre.
*
* Displays two large buttons for binary voting with confirmation modal.
* Integrates with the votes store and auth store for submission and access control.
*/
const props = defineProps<{
sessionId: string
disabled?: boolean
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const submitting = ref(false)
const pendingVote = ref<'pour' | 'contre' | null>(null)
const showConfirm = ref(false)
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
function requestVote(value: 'pour' | 'contre') {
if (isDisabled.value) return
pendingVote.value = value
showConfirm.value = true
}
async function confirmVote() {
if (!pendingVote.value) return
showConfirm.value = false
submitting.value = true
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: pendingVote.value,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
pendingVote.value = null
}
}
function cancelVote() {
showConfirm.value = false
pendingVote.value = null
}
const confirmLabel = computed(() => {
return pendingVote.value === 'pour'
? 'Confirmer le vote POUR'
: 'Confirmer le vote CONTRE'
})
</script>
<template>
<div class="space-y-4">
<!-- Vote buttons -->
<div class="flex gap-4">
<UButton
size="xl"
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'pour'"
icon="i-lucide-thumbs-up"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('pour')"
>
Pour
</UButton>
<UButton
size="xl"
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'contre'"
icon="i-lucide-thumbs-down"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('contre')"
>
Contre
</UButton>
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter
<strong :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
</strong>.
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton
:color="pendingVote === 'pour' ? 'success' : 'error'"
@click="confirmVote"
>
{{ confirmLabel }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* Vote history list for a session.
*
* Displays a table of all votes cast in a session, sorted by date descending.
* For nuanced votes, shows the level label and color. Shows Smith/TechComm badges.
*/
import type { Vote } from '~/stores/votes'
const props = defineProps<{
votes: Vote[]
}>()
/** Nuanced level labels matching VoteNuanced component. */
const nuancedLabels: Record<number, { label: string; color: string }> = {
0: { label: 'CONTRE', color: 'error' },
1: { label: 'PAS DU TOUT D\'ACCORD', color: 'warning' },
2: { label: 'PAS D\'ACCORD', color: 'warning' },
3: { label: 'NEUTRE', color: 'neutral' },
4: { label: 'D\'ACCORD', color: 'success' },
5: { label: 'TOUT A FAIT D\'ACCORD', color: 'success' },
}
/** Sorted votes by date descending. */
const sortedVotes = computed(() => {
return [...props.votes].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
function truncateAddress(address: string): string {
if (address.length <= 16) return address
return `${address.slice(0, 8)}...${address.slice(-6)}`
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function voteLabel(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.label ?? `Niveau ${vote.nuanced_level}`
}
return vote.vote_value === 'pour' ? 'Pour' : 'Contre'
}
function voteColor(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.color ?? 'neutral'
}
return vote.vote_value === 'pour' ? 'success' : 'error'
}
</script>
<template>
<div>
<div v-if="sortedVotes.length === 0" class="text-center py-8">
<UIcon name="i-lucide-vote" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun vote enregistre</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votant</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Vote</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Statut</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="vote in sortedVotes"
:key="vote.id"
class="border-b border-gray-100 dark:border-gray-800"
>
<!-- Voter address -->
<td class="px-4 py-3">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncateAddress(vote.voter_id) }}
</span>
</td>
<!-- Vote value -->
<td class="px-4 py-3">
<UBadge :color="(voteColor(vote) as any)" variant="subtle" size="xs">
{{ voteLabel(vote) }}
</UBadge>
</td>
<!-- Smith / TechComm badges -->
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<UBadge v-if="vote.voter_is_smith" color="info" variant="subtle" size="xs">
Smith
</UBadge>
<UBadge v-if="vote.voter_is_techcomm" color="purple" variant="subtle" size="xs">
TechComm
</UBadge>
<span v-if="!vote.voter_is_smith && !vote.voter_is_techcomm" class="text-xs text-gray-400">
Membre
</span>
</div>
</td>
<!-- Date -->
<td class="px-4 py-3 text-xs text-gray-500">
{{ formatDate(vote.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
/**
* 6-level nuanced vote component.
*
* Displays 6 vote levels from CONTRE (0) to TOUT A FAIT D'ACCORD (5),
* each with a distinctive color. Negative votes (0-2) optionally include
* a comment textarea.
*/
const props = defineProps<{
sessionId: string
disabled?: boolean
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const submitting = ref(false)
const selectedLevel = ref<number | null>(null)
const comment = ref('')
const showConfirm = ref(false)
interface NuancedLevel {
level: number
label: string
color: string
bgClass: string
textClass: string
ringClass: string
}
const levels: NuancedLevel[] = [
{ level: 0, label: 'CONTRE', color: 'red', bgClass: 'bg-red-500', textClass: 'text-red-600 dark:text-red-400', ringClass: 'ring-red-500' },
{ level: 1, label: 'PAS DU TOUT D\'ACCORD', color: 'orange-red', bgClass: 'bg-orange-600', textClass: 'text-orange-700 dark:text-orange-400', ringClass: 'ring-orange-600' },
{ level: 2, label: 'PAS D\'ACCORD', color: 'orange', bgClass: 'bg-orange-400', textClass: 'text-orange-600 dark:text-orange-300', ringClass: 'ring-orange-400' },
{ level: 3, label: 'NEUTRE', color: 'gray', bgClass: 'bg-gray-400', textClass: 'text-gray-600 dark:text-gray-400', ringClass: 'ring-gray-400' },
{ level: 4, label: 'D\'ACCORD', color: 'light-green', bgClass: 'bg-green-400', textClass: 'text-green-600 dark:text-green-400', ringClass: 'ring-green-400' },
{ level: 5, label: 'TOUT A FAIT D\'ACCORD', color: 'green', bgClass: 'bg-green-600', textClass: 'text-green-700 dark:text-green-300', ringClass: 'ring-green-600' },
]
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
/** Initialize selected level from existing vote. */
watchEffect(() => {
if (userVote.value?.nuanced_level !== undefined && userVote.value?.nuanced_level !== null) {
selectedLevel.value = userVote.value.nuanced_level
}
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
/** Whether the comment field should be shown (negative votes). */
const showComment = computed(() => {
return selectedLevel.value !== null && selectedLevel.value <= 2
})
function selectLevel(level: number) {
if (isDisabled.value) return
selectedLevel.value = level
showConfirm.value = true
}
async function confirmVote() {
if (selectedLevel.value === null) return
showConfirm.value = false
submitting.value = true
const voteValue = selectedLevel.value >= 3 ? 'pour' : 'contre'
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: voteValue,
nuanced_level: selectedLevel.value,
comment: showComment.value && comment.value.trim() ? comment.value.trim() : null,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
}
}
function cancelVote() {
showConfirm.value = false
}
function getLevelLabel(level: number): string {
return levels.find(l => l.level === level)?.label ?? ''
}
</script>
<template>
<div class="space-y-4">
<!-- Level buttons -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<button
v-for="lvl in levels"
:key="lvl.level"
:disabled="isDisabled"
class="relative flex flex-col items-center p-4 rounded-lg border-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:class="[
selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level
? `ring-3 ${lvl.ringClass} border-transparent`
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
]"
@click="selectLevel(lvl.level)"
>
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
:class="lvl.bgClass"
>
{{ lvl.level }}
</div>
<span class="text-xs font-medium text-center leading-tight" :class="lvl.textClass">
{{ lvl.label }}
</span>
<!-- Selected indicator -->
<div
v-if="selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-xs"
:class="lvl.bgClass"
>
<UIcon name="i-lucide-check" class="w-3 h-3" />
</div>
</button>
</div>
<!-- Comment for negative votes -->
<div v-if="showComment" class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Commentaire (optionnel pour les votes negatifs)
</label>
<UTextarea
v-model="comment"
placeholder="Expliquez votre position..."
:rows="3"
:disabled="isDisabled"
/>
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ getLevelLabel(userVote.nuanced_level ?? 0) }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter :
<strong>{{ getLevelLabel(selectedLevel ?? 0) }}</strong> (niveau {{ selectedLevel }}).
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton color="primary" :loading="submitting" @click="confirmVote">
Confirmer le vote
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -0,0 +1,98 @@
/**
* Composable for real-time vote formula computation.
*
* Re-exports and wraps the threshold utility functions for reactive use
* in Vue components. Provides convenient methods for threshold calculations,
* inertia factor, required ratio, and adoption checks.
*/
import { wotThreshold, smithThreshold, techcommThreshold } from '~/utils/threshold'
export interface FormulaParams {
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
}
export function useVoteFormula() {
/**
* Compute the WoT threshold for a given set of parameters.
*/
function computeThreshold(wotSize: number, totalVotes: number, params: FormulaParams): number {
return wotThreshold(
wotSize,
totalVotes,
params.majority_pct,
params.base_exponent,
params.gradient_exponent,
params.constant_base,
)
}
/**
* Compute the inertia factor: 1 - (T/W)^G
* Ranges from ~1 (low participation) to ~0 (full participation).
*/
function computeInertiaFactor(
totalVotes: number,
wotSize: number,
gradientExponent: number,
): number {
if (wotSize <= 0 || totalVotes <= 0) return 1.0
const participationRatio = totalVotes / wotSize
return 1.0 - Math.pow(participationRatio, gradientExponent)
}
/**
* Compute the required ratio of "pour" votes at a given participation level.
* requiredRatio = M + (1 - M) * inertiaFactor
*/
function computeRequiredRatio(
totalVotes: number,
wotSize: number,
majorityPct: number,
gradientExponent: number,
): number {
const M = majorityPct / 100
const inertia = computeInertiaFactor(totalVotes, wotSize, gradientExponent)
return M + (1.0 - M) * inertia
}
/**
* Check whether a vote is adopted given all criteria.
* A vote is adopted when it passes the WoT threshold AND
* any applicable Smith/TechComm criteria.
*/
function isAdopted(
votesFor: number,
threshold: number,
smithVotesFor?: number,
smithThresholdVal?: number,
techcommVotesFor?: number,
techcommThresholdVal?: number,
): boolean {
// Main WoT criterion
if (votesFor < threshold) return false
// Smith criterion (if applicable)
if (smithThresholdVal !== undefined && smithThresholdVal > 0) {
if ((smithVotesFor ?? 0) < smithThresholdVal) return false
}
// TechComm criterion (if applicable)
if (techcommThresholdVal !== undefined && techcommThresholdVal > 0) {
if ((techcommVotesFor ?? 0) < techcommThresholdVal) return false
}
return true
}
return {
computeThreshold,
computeInertiaFactor,
computeRequiredRatio,
isAdopted,
smithThreshold,
techcommThreshold,
}
}

View File

@@ -0,0 +1,100 @@
/**
* Composable for WebSocket connectivity to receive live vote updates.
*
* Connects to the backend WS endpoint and allows subscribing to
* individual vote session channels for real-time tally updates.
*/
export function useWebSocket() {
const config = useRuntimeConfig()
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const connected = ref(false)
const lastMessage = ref<any>(null)
/**
* Open a WebSocket connection to the backend live endpoint.
*/
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return
}
const wsUrl = config.public.apiBase
.replace(/^http/, 'ws')
.replace(/\/api\/v1$/, '/api/v1/ws/live')
ws = new WebSocket(wsUrl)
ws.onopen = () => {
connected.value = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
ws.onclose = () => {
connected.value = false
reconnect()
}
ws.onerror = () => {
connected.value = false
}
ws.onmessage = (event: MessageEvent) => {
try {
lastMessage.value = JSON.parse(event.data)
} catch {
lastMessage.value = event.data
}
}
}
/**
* Subscribe to real-time updates for a vote session.
*/
function subscribe(sessionId: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'subscribe', session_id: sessionId }))
}
}
/**
* Unsubscribe from a vote session's updates.
*/
function unsubscribe(sessionId: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId }))
}
}
/**
* Gracefully close the WebSocket connection.
*/
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
connected.value = false
}
/**
* Schedule a reconnection attempt after a delay.
*/
function reconnect() {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, 3000)
}
return { connected, lastMessage, connect, subscribe, unsubscribe, disconnect }
}

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
/**
* Protocol detail page.
*
* Displays full protocol information including name, type, description,
* mode params, formula config, and links to the formula simulator.
*/
const route = useRoute()
const protocols = useProtocolsStore()
const votes = useVotesStore()
const protocolId = computed(() => route.params.id as string)
onMounted(async () => {
await protocols.fetchProtocolById(protocolId.value)
})
const protocol = computed(() => protocols.currentProtocol)
const voteTypeLabel = (voteType: string) => {
switch (voteType) {
case 'binary': return 'Binaire'
case 'nuanced': return 'Nuance'
default: return voteType
}
}
const voteTypeColor = (voteType: string) => {
switch (voteType) {
case 'binary': return 'primary'
case 'nuanced': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
/** Build simulator URL with prefilled params. */
const simulatorLink = computed(() => {
if (!protocol.value?.mode_params) return '/protocols/formulas'
return `/protocols/formulas`
})
</script>
<template>
<div class="space-y-8">
<!-- Header with back link -->
<div class="flex items-center gap-3">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Detail du protocole
</h1>
</div>
<!-- Loading -->
<template v-if="protocols.loading">
<div class="space-y-3">
<USkeleton class="h-12 w-3/4" />
<USkeleton class="h-6 w-1/2" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error -->
<template v-else-if="protocols.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ protocols.error }}</p>
</div>
</UCard>
</template>
<!-- Protocol detail -->
<template v-else-if="protocol">
<!-- Protocol header card -->
<UCard>
<div class="space-y-4">
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
{{ protocol.name }}
</h2>
<p v-if="protocol.description" class="text-gray-600 dark:text-gray-400 mt-1">
{{ protocol.description }}
</p>
<p class="text-xs text-gray-500 mt-2">
Cree le {{ formatDate(protocol.created_at) }}
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle">
Meta-gouverne
</UBadge>
</div>
</div>
</div>
</UCard>
<!-- Mode params -->
<UCard v-if="protocol.mode_params">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres du mode
</h3>
</template>
<ModeParamsDisplay :mode-params="protocol.mode_params" />
</UCard>
<!-- Formula config -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Configuration de la formule : {{ protocol.formula_config.name }}
</h3>
<NuxtLink :to="simulatorLink">
<UButton variant="outline" size="sm" icon="i-lucide-calculator">
Simuler
</UButton>
</NuxtLink>
</div>
</template>
<FormulaDisplay :formula-config="protocol.formula_config" />
</UCard>
<!-- Meta-governance info -->
<UCard v-if="protocol.is_meta_governed">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield" class="text-2xl text-amber-500" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
Protocole meta-gouverne
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les modifications de ce protocole sont soumises au vote selon ses propres regles.
</p>
</div>
</div>
</UCard>
<!-- Related vote sessions -->
<UCard v-if="votes.sessions && votes.sessions.length > 0">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Sessions de vote utilisant ce protocole
</h3>
</template>
<div class="space-y-2">
<div
v-for="session in votes.sessions"
:key="session.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">
{{ session.id.slice(0, 8) }}...
</span>
<StatusBadge :status="session.status" type="vote" class="ml-2" />
</div>
<div class="text-sm text-gray-500">
{{ session.votes_total }} votes
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
/**
* Formula simulator page.
*
* Allows interactive adjustment of formula parameters with live threshold
* computation, a visual gauge, and a table of thresholds at various
* participation levels.
*/
import type { FormulaConfig } from '~/stores/protocols'
import { encodeModeParams } from '~/utils/mode-params'
const { computeThreshold, computeRequiredRatio, computeInertiaFactor } = useVoteFormula()
/** Default formula config for the simulator. */
const formulaConfig = ref<FormulaConfig>({
id: 'simulator',
name: 'Simulateur',
description: null,
duration_days: 30,
majority_pct: 50,
base_exponent: 0.1,
gradient_exponent: 0.2,
constant_base: 0,
smith_exponent: null,
techcomm_exponent: null,
nuanced_min_participants: null,
nuanced_threshold_pct: null,
created_at: new Date().toISOString(),
})
/** Simulation inputs. */
const wotSize = ref(7224)
const simulatedVotes = ref(120)
const simulatedFor = ref(97)
/** Computed threshold for current params. */
const threshold = computed(() => {
try {
return computeThreshold(wotSize.value, simulatedVotes.value, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
} catch {
return 0
}
})
/** Computed required ratio. */
const requiredRatio = computed(() => {
return computeRequiredRatio(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
})
/** Computed inertia factor. */
const inertiaFactor = computed(() => {
return computeInertiaFactor(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.gradient_exponent,
)
})
/** Simulated votes against. */
const simulatedAgainst = computed(() => {
return Math.max(0, simulatedVotes.value - simulatedFor.value)
})
/** Mode params string for current config. */
const modeParamsString = computed(() => {
try {
return encodeModeParams({
duration_days: formulaConfig.value.duration_days,
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
smith_exponent: formulaConfig.value.smith_exponent,
techcomm_exponent: formulaConfig.value.techcomm_exponent,
})
} catch {
return ''
}
})
/** Table of thresholds at various participation levels. */
const participationLevels = [10, 50, 100, 200, 500, 1000, 3000, 5000, 7000]
const thresholdTable = computed(() => {
return participationLevels
.filter(n => n <= wotSize.value)
.map(totalVotes => {
let t: number
let ratio: number
try {
t = computeThreshold(wotSize.value, totalVotes, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
ratio = computeRequiredRatio(
totalVotes,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
} catch {
t = 0
ratio = 0
}
const participation = ((totalVotes / wotSize.value) * 100).toFixed(2)
return {
totalVotes,
threshold: t,
ratio: (ratio * 100).toFixed(1),
participation,
}
})
})
/** Keep simulatedFor within bounds when simulatedVotes changes. */
watch(simulatedVotes, (newTotal) => {
if (simulatedFor.value > newTotal) {
simulatedFor.value = newTotal
}
})
</script>
<template>
<div class="space-y-8">
<!-- Header -->
<div>
<div class="flex items-center gap-3 mb-2">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Simulateur de formule de seuil
</h1>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Ajustez les parametres de la formule et observez le seuil calcule en temps reel.
</p>
</div>
<!-- Current mode params -->
<UCard v-if="modeParamsString">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mode params :</span>
<ModeParamsDisplay :mode-params="modeParamsString" />
</div>
</UCard>
<!-- Formula editor -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres de la formule
</h2>
</template>
<FormulaEditor v-model="formulaConfig" />
</UCard>
<!-- Simulation inputs -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Simulation
</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- WoT size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Taille du corpus WoT (W)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ wotSize }}</span>
</div>
<UInput v-model.number="wotSize" type="number" :min="1" :max="100000" />
</div>
<!-- Total votes -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes totaux (T)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ simulatedVotes }}</span>
</div>
<URange v-model="simulatedVotes" :min="0" :max="wotSize" :step="1" />
</div>
<!-- Votes for -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes pour
</label>
<span class="text-sm font-mono font-bold text-green-600">{{ simulatedFor }}</span>
</div>
<URange v-model="simulatedFor" :min="0" :max="simulatedVotes" :step="1" />
</div>
</div>
</UCard>
<!-- Results -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Resultat
</h2>
</template>
<div class="space-y-6">
<!-- Threshold gauge -->
<ThresholdGauge
:votes-for="simulatedFor"
:votes-against="simulatedAgainst"
:threshold="threshold"
:wot-size="wotSize"
/>
<!-- Key metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Seuil requis</p>
<p class="text-2xl font-bold text-primary">{{ threshold }}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Ratio requis</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ (requiredRatio * 100).toFixed(1) }}%
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Facteur d'inertie</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ inertiaFactor.toFixed(4) }}
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Participation</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ ((simulatedVotes / wotSize) * 100).toFixed(2) }}%
</p>
</div>
</div>
</div>
</UCard>
<!-- Formula display -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Formule
</h2>
</template>
<FormulaDisplay :formula-config="formulaConfig" show-explanation />
</UCard>
<!-- Threshold table -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Seuils par niveau de participation
</h2>
</template>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votes totaux (T)</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Participation</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Ratio requis</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Seuil (votes pour)</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in thresholdTable"
:key="row.totalVotes"
class="border-b border-gray-100 dark:border-gray-800"
:class="row.totalVotes === simulatedVotes ? 'bg-primary-50 dark:bg-primary-900/20' : ''"
>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{{ row.totalVotes }}</td>
<td class="px-4 py-3 text-gray-600">{{ row.participation }}%</td>
<td class="px-4 py-3 text-gray-600">{{ row.ratio }}%</td>
<td class="px-4 py-3 font-mono font-bold text-primary">{{ row.threshold }}</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</div>
</template>

View File

@@ -1,5 +1,24 @@
<script setup lang="ts">
/**
* Protocols index page.
*
* Lists all voting protocols with ModeParamsDisplay component,
* links to protocol detail pages, and provides creation modal
* and simulator link.
*/
const protocols = useProtocolsStore()
const auth = useAuthStore()
const showCreateModal = ref(false)
const creating = ref(false)
/** Creation form state. */
const newProtocol = reactive({
name: '',
description: '',
vote_type: 'binary',
formula_config_id: '',
})
onMounted(async () => {
await Promise.all([
@@ -24,14 +43,17 @@ const voteTypeColor = (voteType: string) => {
}
}
function formatModeParamsDisplay(modeParams: string | null): string {
if (!modeParams) return '-'
try {
return formatModeParams(modeParams)
} catch {
return modeParams
}
}
const voteTypeOptions = [
{ label: 'Binaire (Pour/Contre)', value: 'binary' },
{ label: 'Nuance (6 niveaux)', value: 'nuanced' },
]
const formulaOptions = computed(() => {
return protocols.formulas.map(f => ({
label: f.name,
value: f.id,
}))
})
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
@@ -40,18 +62,61 @@ function formatDate(dateStr: string): string {
year: 'numeric',
})
}
function openCreateModal() {
newProtocol.name = ''
newProtocol.description = ''
newProtocol.vote_type = 'binary'
newProtocol.formula_config_id = ''
showCreateModal.value = true
}
async function createProtocol() {
if (!newProtocol.name.trim() || !newProtocol.formula_config_id) return
creating.value = true
try {
await protocols.createProtocol({
name: newProtocol.name.trim(),
description: newProtocol.description.trim() || null,
vote_type: newProtocol.vote_type,
formula_config_id: newProtocol.formula_config_id,
})
showCreateModal.value = false
await protocols.fetchProtocols()
} finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-8">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Protocoles de vote
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Configuration des protocoles de vote et formules de seuil WoT
</p>
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Protocoles de vote
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Configuration des protocoles de vote et formules de seuil WoT
</p>
</div>
<div class="flex items-center gap-3">
<NuxtLink to="/protocols/formulas">
<UButton variant="outline" icon="i-lucide-calculator" size="sm">
Simulateur de formules
</UButton>
</NuxtLink>
<UButton
v-if="auth.isAuthenticated"
icon="i-lucide-plus"
size="sm"
@click="openCreateModal"
>
Nouveau protocole
</UButton>
</div>
</div>
<!-- Loading state -->
@@ -88,85 +153,86 @@ function formatDate(dateStr: string): string {
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<UCard
<NuxtLink
v-for="protocol in protocols.protocols"
:key="protocol.id"
:to="`/protocols/${protocol.id}`"
class="block"
>
<div class="space-y-4">
<!-- Protocol header -->
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ protocol.name }}
</h3>
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5">
{{ protocol.description }}
</p>
<UCard class="hover:ring-2 hover:ring-primary-300 dark:hover:ring-primary-700 transition-all cursor-pointer">
<div class="space-y-4">
<!-- Protocol header -->
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ protocol.name }}
</h3>
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5">
{{ protocol.description }}
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
Meta-gouverne
</UBadge>
</div>
</div>
<div class="flex items-center gap-2">
<UBadge :color="voteTypeColor(protocol.vote_type)" variant="subtle" size="xs">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
Meta-gouverne
</UBadge>
</div>
</div>
<!-- Mode params -->
<div v-if="protocol.mode_params" class="p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs">
<div class="flex items-center gap-2 mb-1">
<span class="font-mono font-bold text-primary">{{ protocol.mode_params }}</span>
<!-- Mode params with component -->
<div v-if="protocol.mode_params">
<ModeParamsDisplay :mode-params="protocol.mode_params" />
</div>
<p class="text-gray-500">{{ formatModeParamsDisplay(protocol.mode_params) }}</p>
</div>
<!-- Formula config summary -->
<div class="border-t border-gray-100 dark:border-gray-800 pt-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Formule : {{ protocol.formula_config.name }}
</h4>
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="text-gray-400 block">Duree</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.duration_days }}j
</span>
</div>
<div>
<span class="text-gray-400 block">Majorite</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.majority_pct }}%
</span>
</div>
<div>
<span class="text-gray-400 block">Base</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.base_exponent }}
</span>
</div>
<div>
<span class="text-gray-400 block">Gradient</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.gradient_exponent }}
</span>
</div>
<div v-if="protocol.formula_config.smith_exponent !== null">
<span class="text-gray-400 block">Smith</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.smith_exponent }}
</span>
</div>
<div v-if="protocol.formula_config.techcomm_exponent !== null">
<span class="text-gray-400 block">TechComm</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.techcomm_exponent }}
</span>
<!-- Formula config summary -->
<div class="border-t border-gray-100 dark:border-gray-800 pt-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Formule : {{ protocol.formula_config.name }}
</h4>
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="text-gray-400 block">Duree</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.duration_days }}j
</span>
</div>
<div>
<span class="text-gray-400 block">Majorite</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.majority_pct }}%
</span>
</div>
<div>
<span class="text-gray-400 block">Base</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.base_exponent }}
</span>
</div>
<div>
<span class="text-gray-400 block">Gradient</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.gradient_exponent }}
</span>
</div>
<div v-if="protocol.formula_config.smith_exponent !== null">
<span class="text-gray-400 block">Smith</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.smith_exponent }}
</span>
</div>
<div v-if="protocol.formula_config.techcomm_exponent !== null">
<span class="text-gray-400 block">TechComm</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.techcomm_exponent }}
</span>
</div>
</div>
</div>
</div>
</div>
</UCard>
</UCard>
</NuxtLink>
</div>
</div>
@@ -253,5 +319,68 @@ function formatDate(dateStr: string): string {
</div>
</UCard>
</template>
<!-- Create protocol modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<div class="p-6 space-y-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Nouveau protocole de vote
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Nom du protocole
</label>
<UInput v-model="newProtocol.name" placeholder="Ex: Vote standard G1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Description (optionnel)
</label>
<UTextarea v-model="newProtocol.description" placeholder="Description du protocole..." :rows="2" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Type de vote
</label>
<USelect
v-model="newProtocol.vote_type"
:items="voteTypeOptions"
value-key="value"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Configuration de formule
</label>
<USelect
v-model="newProtocol.formula_config_id"
:items="formulaOptions"
placeholder="Selectionnez une formule..."
value-key="value"
/>
</div>
</div>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="showCreateModal = false">
Annuler
</UButton>
<UButton
:loading="creating"
:disabled="!newProtocol.name.trim() || !newProtocol.formula_config_id"
@click="createProtocol"
>
Creer le protocole
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -32,9 +32,52 @@ export interface VotingProtocol {
formula_config: FormulaConfig
}
export interface ProtocolCreate {
name: string
description: string | null
vote_type: string
formula_config_id: string
}
export interface FormulaCreate {
name: string
description?: string | null
duration_days: number
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
smith_exponent?: number | null
techcomm_exponent?: number | null
nuanced_min_participants?: number | null
nuanced_threshold_pct?: number | null
}
export interface SimulateParams {
wot_size: number
total_votes: number
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
smith_wot_size?: number
smith_exponent?: number
techcomm_size?: number
techcomm_exponent?: number
}
export interface SimulateResult {
threshold: number
smith_threshold: number | null
techcomm_threshold: number | null
inertia_factor: number
required_ratio: number
}
interface ProtocolsState {
protocols: VotingProtocol[]
formulas: FormulaConfig[]
currentProtocol: VotingProtocol | null
loading: boolean
error: string | null
}
@@ -43,6 +86,7 @@ export const useProtocolsStore = defineStore('protocols', {
state: (): ProtocolsState => ({
protocols: [],
formulas: [],
currentProtocol: null,
loading: false,
error: null,
}),
@@ -96,5 +140,89 @@ export const useProtocolsStore = defineStore('protocols', {
this.loading = false
}
},
/**
* Fetch a single protocol by ID.
*/
async fetchProtocolById(id: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.currentProtocol = await $api<VotingProtocol>(`/protocols/${id}`)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Protocole introuvable'
} finally {
this.loading = false
}
},
/**
* Create a new voting protocol.
*/
async createProtocol(data: ProtocolCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const protocol = await $api<VotingProtocol>('/protocols/', {
method: 'POST',
body: data,
})
this.protocols.push(protocol)
return protocol
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du protocole'
throw err
} finally {
this.loading = false
}
},
/**
* Create a new formula configuration.
*/
async createFormula(data: FormulaCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const formula = await $api<FormulaConfig>('/protocols/formulas', {
method: 'POST',
body: data,
})
this.formulas.push(formula)
return formula
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la formule'
throw err
} finally {
this.loading = false
}
},
/**
* Simulate formula computation on the backend.
*/
async simulate(params: SimulateParams) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
return await $api<SimulateResult>('/protocols/simulate', {
method: 'POST',
body: params,
})
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la simulation'
throw err
} finally {
this.loading = false
}
},
},
})

View File

@@ -62,6 +62,17 @@ export interface VoteResult {
techcomm_pass: boolean
}
export interface ThresholdDetails {
wot_threshold: number
smith_threshold: number | null
techcomm_threshold: number | null
wot_pass: boolean
smith_pass: boolean | null
techcomm_pass: boolean | null
inertia_factor: number
required_ratio: number
}
export interface VoteCreate {
session_id: string
vote_value: string
@@ -71,10 +82,27 @@ export interface VoteCreate {
signed_payload: string
}
export interface VoteSessionCreate {
decision_id?: string | null
item_version_id?: string | null
voting_protocol_id: string
wot_size?: number
smith_size?: number
techcomm_size?: number
}
export interface SessionFilters {
status?: string
voting_protocol_id?: string
decision_id?: string
}
interface VotesState {
currentSession: VoteSession | null
votes: Vote[]
result: VoteResult | null
thresholdDetails: ThresholdDetails | null
sessions: VoteSession[]
loading: boolean
error: string | null
}
@@ -84,6 +112,8 @@ export const useVotesStore = defineStore('votes', {
currentSession: null,
votes: [],
result: null,
thresholdDetails: null,
sessions: [],
loading: false,
error: null,
}),
@@ -166,6 +196,94 @@ export const useVotesStore = defineStore('votes', {
}
},
/**
* Fetch threshold details for a session.
*/
async fetchThresholdDetails(sessionId: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.thresholdDetails = await $api<ThresholdDetails>(
`/votes/sessions/${sessionId}/threshold`,
)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des details du seuil'
} finally {
this.loading = false
}
},
/**
* Close a vote session.
*/
async closeSession(sessionId: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const session = await $api<VoteSession>(`/votes/sessions/${sessionId}/close`, {
method: 'POST',
})
this.currentSession = session
// Refresh result after closing
this.result = await $api<VoteResult>(`/votes/sessions/${sessionId}/result`)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la fermeture de la session'
throw err
} finally {
this.loading = false
}
},
/**
* Fetch a list of vote sessions with optional filters.
*/
async fetchSessions(filters?: SessionFilters) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (filters?.status) query.status = filters.status
if (filters?.voting_protocol_id) query.voting_protocol_id = filters.voting_protocol_id
if (filters?.decision_id) query.decision_id = filters.decision_id
this.sessions = await $api<VoteSession[]>('/votes/sessions', { query })
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des sessions'
} finally {
this.loading = false
}
},
/**
* Create a new vote session.
*/
async createSession(data: VoteSessionCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const session = await $api<VoteSession>('/votes/sessions', {
method: 'POST',
body: data,
})
this.sessions.push(session)
return session
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la session'
throw err
} finally {
this.loading = false
}
},
/**
* Clear the current session state.
*/
@@ -173,6 +291,7 @@ export const useVotesStore = defineStore('votes', {
this.currentSession = null
this.votes = []
this.result = null
this.thresholdDetails = null
},
},
})