diff --git a/.gitignore b/.gitignore index 8a54fde..e51de09 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ backend/uploads/ htmlcov/ .coverage coverage.xml + +# Sensitive dev files +IDENTIFIANTS.txt +data/DEV-CREDENTIALS.md diff --git a/backend/app/routers/votes.py b/backend/app/routers/votes.py index 35d1dbf..af20998 100644 --- a/backend/app/routers/votes.py +++ b/backend/app/routers/votes.py @@ -39,6 +39,23 @@ async def _load_commune_context(commune_id: int, db: AsyncSession): return params, households +# ── Public endpoint: overlay of all vote curves for citizens ── + +@router.get("/communes/{slug}/votes/current/overlay") +async def current_overlay(slug: str, db: AsyncSession = Depends(get_db)): + """Public: returns all active vote curves (params only, no auth required).""" + commune = await _get_commune_by_slug(slug, db) + result = await db.execute( + select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True) + ) + votes = result.scalars().all() + return [ + {"vinf": v.vinf, "a": v.a, "b": v.b, + "c": v.c, "d": v.d, "e": v.e, "computed_p0": v.computed_p0} + for v in votes + ] + + # ── Public endpoint: current median curve for citizens ── @router.get("/communes/{slug}/votes/current") diff --git a/frontend/app/app.vue b/frontend/app/app.vue index 6a03359..f8eacfa 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -3,11 +3,3 @@ - - diff --git a/frontend/app/middleware/admin.ts b/frontend/app/middleware/admin.ts index aec55b9..634ba62 100644 --- a/frontend/app/middleware/admin.ts +++ b/frontend/app/middleware/admin.ts @@ -6,6 +6,6 @@ export default defineNuxtRouteMiddleware((to) => { const authStore = useAuthStore() if (!authStore.isAuthenticated || !authStore.isAdmin) { - return navigateTo('/login') + return navigateTo('/') } }) diff --git a/frontend/app/pages/commune/[slug]/index.vue b/frontend/app/pages/commune/[slug]/index.vue index 8644a67..258dba4 100644 --- a/frontend/app/pages/commune/[slug]/index.vue +++ b/frontend/app/pages/commune/[slug]/index.vue @@ -23,21 +23,38 @@

Tarification progressive — Prix au m3

- - Mediane de {{ curveData.vote_count }} vote(s) - - Courbe par defaut +
+ + + {{ curveData.vote_count }} vote(s) + + Courbe par defaut +
-

- Deplacez les poignees pour ajuster la forme de la courbe. - Le prix d'inflexion p0 s'ajuste automatiquement pour equilibrer les recettes. +

+ Deplacez les poignees pour ajuster la courbe. + p0 s'equilibre automatiquement.

+ +
+ + + + + + + {{ zoomVolMin.toFixed(0) }}-{{ zoomVolMax.toFixed(0) }} m3 / {{ zoomPriceMin.toFixed(1) }}-{{ zoomPriceMax.toFixed(1) }} EUR + +
+
@@ -50,34 +67,40 @@ @mouseleave="onMouseUp" @touchmove.prevent="onTouchMove" @touchend="onMouseUp" + @wheel.prevent="onWheel" > - {{ v }} - - {{ p }} + {{ p.toFixed(p < 1 ? 1 : 0) }} - volume (m3) - Prix/m3 + EUR/m3 + + + + + @@ -88,19 +111,19 @@ - - + + - + - - - + p0 = {{ localP0.toFixed(2) }} EUR/m3 @@ -113,7 +136,6 @@ @mousedown.prevent="startDrag(key)" @touchstart.prevent="startDrag(key)" /> - @@ -345,28 +367,79 @@ const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlu const submitting = ref(false) const voteSuccess = ref(false) -// ── Chart 1: Interactive Bezier ── +// ── Chart 1: Interactive Bezier with zoom ── const W = 620 const H = 380 const margin = { top: 20, right: 20, bottom: 28, left: 45 } const plotW = W - margin.left - margin.right const plotH = H - margin.top - margin.bottom -function cx(v: number) { return margin.left + (v / vmax.value) * plotW } -function cy(p: number) { return margin.top + plotH - (p / pmax.value) * plotH } -function fromX(sx: number) { return ((sx - margin.left) / plotW) * vmax.value } -function fromY(sy: number) { return ((margin.top + plotH - sy) / plotH) * pmax.value } +// Zoom state (data-space ranges) +const zoomVolMin = ref(0) +const zoomVolMax = ref(2100) +const zoomPriceMin = ref(0) +const zoomPriceMax = ref(20) + +// Outlier overlay +const showOutliers = ref(false) +const outlierVotes = ref([]) + +// Zoom-aware coordinate transforms +function cx(v: number) { return margin.left + ((v - zoomVolMin.value) / (zoomVolMax.value - zoomVolMin.value)) * plotW } +function cy(p: number) { return margin.top + plotH - ((p - zoomPriceMin.value) / (zoomPriceMax.value - zoomPriceMin.value)) * plotH } +function fromX(sx: number) { return zoomVolMin.value + ((sx - margin.left) / plotW) * (zoomVolMax.value - zoomVolMin.value) } +function fromY(sy: number) { return zoomPriceMin.value + ((margin.top + plotH - sy) / plotH) * (zoomPriceMax.value - zoomPriceMin.value) } + +function zoomIn() { + const dv = (zoomVolMax.value - zoomVolMin.value) * 0.15 + const dp = (zoomPriceMax.value - zoomPriceMin.value) * 0.15 + zoomVolMin.value += dv; zoomVolMax.value -= dv + zoomPriceMin.value += dp; zoomPriceMax.value -= dp +} +function zoomOut() { + const dv = (zoomVolMax.value - zoomVolMin.value) * 0.2 + const dp = (zoomPriceMax.value - zoomPriceMin.value) * 0.2 + zoomVolMin.value = Math.max(0, zoomVolMin.value - dv) + zoomVolMax.value = Math.min(vmax.value, zoomVolMax.value + dv) + zoomPriceMin.value = Math.max(0, zoomPriceMin.value - dp) + zoomPriceMax.value = Math.min(pmax.value, zoomPriceMax.value + dp) +} +function zoomReset() { + zoomVolMin.value = 0; zoomVolMax.value = vmax.value + zoomPriceMin.value = 0; zoomPriceMax.value = pmax.value +} +function zoomPreset(tier: string) { + if (tier === 'tier1') { + zoomVolMin.value = 0 + zoomVolMax.value = Math.min(bp.vinf * 1.3, vmax.value) + zoomPriceMin.value = 0 + zoomPriceMax.value = Math.min(localP0.value * 1.5, pmax.value) + } else { + zoomVolMin.value = Math.max(0, bp.vinf * 0.8) + zoomVolMax.value = vmax.value + zoomPriceMin.value = Math.max(0, localP0.value * 0.7) + zoomPriceMax.value = pmax.value + } +} +function onWheel(e: WheelEvent) { + if (e.deltaY < 0) zoomIn() + else zoomOut() +} const gridVols = computed(() => { - const step = Math.ceil(vmax.value / 7 / 100) * 100 + const range = zoomVolMax.value - zoomVolMin.value + const step = Math.max(10, Math.ceil(range / 7 / (range > 500 ? 100 : 10)) * (range > 500 ? 100 : 10)) const arr: number[] = [] - for (let v = step; v < vmax.value; v += step) arr.push(v) + const start = Math.ceil(zoomVolMin.value / step) * step + for (let v = start; v < zoomVolMax.value; v += step) arr.push(v) return arr }) const gridPrices = computed(() => { - const step = Math.ceil(pmax.value / 5) + const range = zoomPriceMax.value - zoomPriceMin.value + const step = range > 5 ? Math.ceil(range / 5) : range > 1 ? 1 : 0.5 const arr: number[] = [] - for (let p = step; p <= pmax.value; p += step) arr.push(p) + const start = Math.ceil(zoomPriceMin.value / step) * step + for (let p = start; p <= zoomPriceMax.value; p += step) arr.push(p) return arr }) @@ -399,6 +472,16 @@ const tier2Path = computed(() => { return `M ${cx(c.p4.x)} ${cy(c.p4.y)} C ${cx(c.p5.x)} ${cy(c.p5.y)}, ${cx(c.p6.x)} ${cy(c.p6.y)}, ${cx(c.p7.x)} ${cy(c.p7.y)}` }) +// Outlier vote paths +function outlierPath(vote: any, tier: number): string { + const p0 = vote.computed_p0 || localP0.value + const oc = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e) + if (tier === 1) { + return `M ${cx(oc.p1.x)} ${cy(oc.p1.y)} C ${cx(oc.p2.x)} ${cy(oc.p2.y)}, ${cx(oc.p3.x)} ${cy(oc.p3.y)}, ${cx(oc.p4.x)} ${cy(oc.p4.y)}` + } + return `M ${cx(oc.p4.x)} ${cy(oc.p4.y)} C ${cx(oc.p5.x)} ${cy(oc.p5.y)}, ${cx(oc.p6.x)} ${cy(oc.p6.y)}, ${cx(oc.p7.x)} ${cy(oc.p7.y)}` +} + // ── Drag handling ── const svgRef = ref(null) const dragging = ref(null) @@ -417,7 +500,7 @@ function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handle function onMouseUp() { if (dragging.value) { dragging.value = null - debouncedServerCompute() + serverCompute() } } @@ -451,29 +534,12 @@ function handleDrag(pt: { x: number; y: number }) { break } } - recalculate() -} - -function recalculate() { - if (!households.value.length) return - localP0.value = computeP0( - households.value, recettes.value, abop.value, abos.value, - bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e, - ) - const result = computeImpacts( - households.value, recettes.value, abop.value, abos.value, - bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e, - ) - impacts.value = result.impacts.map(imp => ({ - volume: imp.volume, - old_price: imp.oldPrice, - new_price_rp: imp.newPriceRP, - new_price_rs: imp.newPriceRS, - })) + // p0 stays fixed during drag — curve updates reactively via bp → cp → SVG paths + // Server will compute authoritative p0 + impacts on mouseUp } let serverTimeout: ReturnType | null = null -function debouncedServerCompute() { +function serverCompute() { if (serverTimeout) clearTimeout(serverTimeout) serverTimeout = setTimeout(async () => { try { @@ -489,7 +555,7 @@ function debouncedServerCompute() { new_price_rs: imp.new_price_rs, })) } catch {} - }, 300) + }, 150) } // ── Chart 2: Baseline linear model ── @@ -593,12 +659,14 @@ onMounted(async () => { params.value = p curveData.value = curve - // Set tariff params + // Set tariff params + zoom vmax.value = p.vmax pmax.value = p.pmax recettes.value = p.recettes abop.value = p.abop abos.value = p.abos + zoomVolMax.value = p.vmax + zoomPriceMax.value = p.pmax // Set initial Bezier params from median (or default) if (curve.median) { @@ -622,6 +690,11 @@ onMounted(async () => { for (let i = 0; i < (stats.rp_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RP' }) for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' }) households.value = hh + + // Load all vote curves for outlier overlay (public endpoint needed) + try { + outlierVotes.value = await api.get(`/communes/${slug}/votes/current/overlay`) + } catch { /* endpoint may not exist yet */ } } catch (e: any) { loadError.value = e.message } finally { @@ -648,6 +721,31 @@ function renderMarkdown(md: string): string { diff --git a/frontend/app/plugins/auth-restore.client.ts b/frontend/app/plugins/auth-restore.client.ts new file mode 100644 index 0000000..fe677ee --- /dev/null +++ b/frontend/app/plugins/auth-restore.client.ts @@ -0,0 +1,8 @@ +/** + * Client-only plugin: restores auth state from localStorage + * BEFORE route middleware runs. + */ +export default defineNuxtPlugin(() => { + const authStore = useAuthStore() + authStore.restore() +})