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:
243
frontend/app/components/protocols/FormulaEditor.vue
Normal file
243
frontend/app/components/protocols/FormulaEditor.vue
Normal 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>
|
||||
61
frontend/app/components/protocols/ModeParamsDisplay.vue
Normal file
61
frontend/app/components/protocols/ModeParamsDisplay.vue
Normal 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>
|
||||
64
frontend/app/components/protocols/ProtocolPicker.vue
Normal file
64
frontend/app/components/protocols/ProtocolPicker.vue
Normal 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>
|
||||
Reference in New Issue
Block a user